| @@ -0,0 +1,5 @@ | |||
| 1 | + | <?php require_once 'engine/init.php'; theme_open(); | |
| 2 | + | ||
| 3 | + | view('success'); | |
| 4 | + | ||
| 5 | + | theme_close(); |
| @@ -0,0 +1,30 @@ | |||
| 1 | + | <?php require_once 'engine/init.php'; theme_open(); | |
| 2 | + | ||
| 3 | + | // Staff list, grouped by position. Cached; the view only renders $srtGrp. | |
| 4 | + | $cache = new Cache('engine/cache/support'); | |
| 5 | + | if ($cache->hasExpired()) { | |
| 6 | + | // Fetch all staffs in-game. | |
| 7 | + | if (znote_server_adapter()->normalizedEngine() === 'TFS_03') { | |
| 8 | + | $staffs = support_list03(); | |
| 9 | + | } else $staffs = support_list(); | |
| 10 | + | // Fetch group ids and names from config.php | |
| 11 | + | $groups = $config['ingame_positions']; | |
| 12 | + | // Loops through groups, separating each group element into an ID variable and name variable | |
| 13 | + | foreach ($groups as $group_id => $group_name) { | |
| 14 | + | // Loops through list of staffs | |
| 15 | + | if (!empty($staffs)) | |
| 16 | + | foreach ($staffs as $staff) { | |
| 17 | + | if ($staff['group_id'] == $group_id) $srtGrp[$group_name][] = $staff; | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | if (!empty($srtGrp)) { | |
| 21 | + | $cache->setContent($srtGrp); | |
| 22 | + | $cache->save(); | |
| 23 | + | } | |
| 24 | + | } else { | |
| 25 | + | $srtGrp = $cache->load(); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | view('support'); | |
| 29 | + | ||
| 30 | + | theme_close(); |
| @@ -0,0 +1,32 @@ | |||
| 1 | + | <?php require_once 'engine/init.php'; theme_open(); | |
| 2 | + | ||
| 3 | + | ||
| 4 | + | $staffChars = db()->fetchAll(" | |
| 5 | + | SELECT `id`, `name`, `group_id`, `sex`, `looktype`, `lookhead`, `lookbody`, `looklegs`, `lookfeet`, `lookaddons` | |
| 6 | + | FROM `players` | |
| 7 | + | WHERE `group_id` > 1 | |
| 8 | + | ORDER BY `group_id` DESC, `name` ASC; | |
| 9 | + | "); | |
| 10 | + | $staffChars = is_array($staffChars) ? $staffChars : array(); | |
| 11 | + | ||
| 12 | + | $staffGroups = array(); | |
| 13 | + | foreach ($staffChars as $member) { | |
| 14 | + | $gid = (int)$member['group_id']; | |
| 15 | + | if (!isset($staffGroups[$gid])) { | |
| 16 | + | $staffGroups[$gid] = array( | |
| 17 | + | 'label' => group_id_to_name($gid) ?: t('team.unnamed_group', ['id' => $gid]), | |
| 18 | + | 'members' => array(), | |
| 19 | + | ); | |
| 20 | + | } | |
| 21 | + | $staffGroups[$gid]['members'][] = $member; | |
| 22 | + | } | |
| 23 | + | krsort($staffGroups); | |
| 24 | + | ||
| 25 | + | $loadOutfits = !empty($config['show_outfits']['imageServer']); | |
| 26 | + | ||
| 27 | + | view('team', [ | |
| 28 | + | 'staffGroups' => $staffGroups, | |
| 29 | + | 'loadOutfits' => $loadOutfits, | |
| 30 | + | ]); | |
| 31 | + | ||
| 32 | + | theme_close(); |
| @@ -0,0 +1,26 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | require_once dirname(__DIR__) . '/vendor/autoload.php'; | |
| 4 | + | ||
| 5 | + | define('ACP_ROOT', dirname(__DIR__) . '/admin'); | |
| 6 | + | ||
| 7 | + | require_once dirname(__DIR__) . '/engine/function/general.php'; | |
| 8 | + | require_once dirname(__DIR__) . '/engine/function/extensions.php'; | |
| 9 | + | require_once dirname(__DIR__) . '/engine/function/plugins.php'; | |
| 10 | + | require_once dirname(__DIR__) . '/engine/function/migrations.php'; | |
| 11 | + | require_once dirname(__DIR__) . '/engine/function/updater.php'; | |
| 12 | + | require_once dirname(__DIR__) . '/engine/function/payments.php'; | |
| 13 | + | require_once dirname(__DIR__) . '/admin/bootstrap.php'; | |
| 14 | + | ||
| 15 | + | if (!function_exists('t_default')) { | |
| 16 | + | function t_default(string $key, string $default = ''): string { | |
| 17 | + | return $default; | |
| 18 | + | } | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | if (!function_exists('theme_sanitize')) { | |
| 22 | + | function theme_sanitize(string $name): string { | |
| 23 | + | $name = strtolower(trim($name)); | |
| 24 | + | return preg_match('/^[a-z0-9_-]{1,64}$/', $name) === 1 ? $name : ''; | |
| 25 | + | } | |
| 26 | + | } |
| @@ -0,0 +1,28 @@ | |||
| 1 | + | -----BEGIN PRIVATE KEY----- | |
| 2 | + | MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDrT8altlCv5VJp | |
| 3 | + | 0bveLec+5DUz1RtpZDJXWKYQnqqyJXsYjcCQhU5mn6CH5Gelbtc6FBAyWH4pPLcR | |
| 4 | + | oZzkD0xTvZboHXMzZbvdHVqwWbc3nTHkKeHwQvAmUzOlrc7rwB6pZWtBdz3RsJPW | |
| 5 | + | n1hgeDwnZKnsgtK7IAXsBliBEnWOpYn70fQnCq7cwgJIF+FkWKmUpuB3/d75+0Ua | |
| 6 | + | rhXEBjaq+sOc9pSdhduRHsr1a7zU3YyxssvBeuniybT5BMGDk5o1XT17NfoFHK5W | |
| 7 | + | MpXGn59VL3C1wUrXW4GHFZ2imCTIeDRHHZZ6I2M98hCOUQeOHoU1VlFZRioFJasv | |
| 8 | + | s8Xgqbv1AgMBAAECggEAXDCpFwtSmVcy7pppDCbXzmACKAh58NR8lJP6m9BN9/WP | |
| 9 | + | QJNoujY//RzU++iUYtAGKo7puY/J/cX3w0SZ/w+vS6+fi9jd5WkaLQrvGOBNU9CW | |
| 10 | + | V1nBVywigiFevq5Vvy9J0/wUBVX+NkUD5rYPHdx9VMGvgSXQAdgN+eaSjh75R6T7 | |
| 11 | + | DWB9PLFRaWOAHrKYEuIm+yoWdpYVRKQrHffqXnxlyyu/EIV6PJeJHiSKedwPXueZ | |
| 12 | + | ZS5s3CWa1O1coFBSDmCHWdcUy7BWgRJuv5nXxY6wuqPqyOw4o7PXw0J1n/Cim22j | |
| 13 | + | Ge1jT97jT7fCx00o6GBuzHOM0vIOqxrtfH05AzH7EQKBgQD5zj8CTlYMsDZEsebk | |
| 14 | + | Oua3Vk3wEBa67r8a9USwNIfD/auchuFZfASmV/RRRJswWRXa1lC85QDPn7rpyLXG | |
| 15 | + | X3FZsUh7OXwhQnFEmCdpy6SRl9lqrmQH/vsYSNHeTv8+5jneZV0Ezq9HKqRoboTP | |
| 16 | + | ezGxa0DWV5Uj4HtP62WW/BkgLwKBgQDxJYXB+g+fUL/Pl1xaOxcoOugtdTfIaJ+X | |
| 17 | + | hSMfOa5XzRRYTCgv50y/Hf1xXxJrwERclRLVNOjnYz2E+vo1gDwJxlcOPlMhNhPd | |
| 18 | + | YPQ/AoicTZTSpyPQ53ynEZM4+zyqlEXEoyxRFsDFL/KVB7/u9KRAQi8SX04FksM8 | |
| 19 | + | morT/51ZGwKBgQC1OdxaVux0bg4gzhOcteKVVUZbh8CFwxjffNplHubz1/99Ihkw | |
| 20 | + | axmQeDSmFKilbau+REb0kwqAlffrDRJapPk9wbC8vNqB4or74YqOZQ+yFEDF9Vha | |
| 21 | + | uK//USz4I8VnI20OG+lcyHk+nwABR1SQlWZauV2jYoyvJ3cuZq8f1yp/PwKBgBoB | |
| 22 | + | zfcpnN21u7oLvO4OSWURVVDxv15hyjRxK2SGuALIH1WWgQ8JhwFlnpvHgRkV10mU | |
| 23 | + | 2j8cQbISxeO9nZZ/ifoT5fenSRff2Sya9DyHbWxOAarmU7qH/K2X+6S9k8Fh1FRs | |
| 24 | + | tK7aIVgi36qq90wyHjS/7ouws51uQpgaorZSbwnZAoGAePE35qaLmjvZ0m2nD7ny | |
| 25 | + | ZF+RVyUxBVFmPeokIwQljZxfpPYe59DqCUcTHg3HNQ9jwVyJL3GHTpcFdeKEG4If | |
| 26 | + | iq5/pHNz7pA7+3H2I2robTh2qhYg1MchKRRMYYq6bsGnnWLVajXIJ4V5GMpx08ZD | |
| 27 | + | ZF65tz7sTOgbm+sytyA7gfg= | |
| 28 | + | -----END PRIVATE KEY----- |
| @@ -0,0 +1,9 @@ | |||
| 1 | + | -----BEGIN PUBLIC KEY----- | |
| 2 | + | MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA60/GpbZQr+VSadG73i3n | |
| 3 | + | PuQ1M9UbaWQyV1imEJ6qsiV7GI3AkIVOZp+gh+RnpW7XOhQQMlh+KTy3EaGc5A9M | |
| 4 | + | U72W6B1zM2W73R1asFm3N50x5Cnh8ELwJlMzpa3O68AeqWVrQXc90bCT1p9YYHg8 | |
| 5 | + | J2Sp7ILSuyAF7AZYgRJ1jqWJ+9H0Jwqu3MICSBfhZFiplKbgd/3e+ftFGq4VxAY2 | |
| 6 | + | qvrDnPaUnYXbkR7K9Wu81N2MsbLLwXrp4sm0+QTBg5OaNV09ezX6BRyuVjKVxp+f | |
| 7 | + | VS9wtcFK11uBhxWdopgkyHg0Rx2WeiNjPfIQjlEHjh6FNVZRWUYqBSWrL7PF4Km7 | |
| 8 | + | 9QIDAQAB | |
| 9 | + | -----END PUBLIC KEY----- |
| @@ -0,0 +1,92 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | ||
| 9 | + | final class AdminPermissionsTest extends TestCase | |
| 10 | + | { | |
| 11 | + | protected function setUp(): void | |
| 12 | + | { | |
| 13 | + | $GLOBALS['config'] = [ | |
| 14 | + | 'ServerEngine' => 'TFS_10', | |
| 15 | + | 'page_admin_access' => ['OwnerAccount'], | |
| 16 | + | 'page_admin_roles' => [ | |
| 17 | + | 'ModeratorAccount' => ['gallery', 'reports'], | |
| 18 | + | 'SupportAccount' => 'helpdesk', | |
| 19 | + | ], | |
| 20 | + | ]; | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | public function testOwnerNameGrantsTheOwnerRole(): void | |
| 24 | + | { | |
| 25 | + | $roles = \admin_roles(['name' => 'OwnerAccount']); | |
| 26 | + | $this->assertSame(['owner'], $roles); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | public function testAssignedModulesAreHonoured(): void | |
| 30 | + | { | |
| 31 | + | $roles = \admin_roles(['name' => 'ModeratorAccount']); | |
| 32 | + | $this->assertSame(['gallery', 'reports'], $roles); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | public function testASingleAssignedModuleStringIsNormalisedToAnArray(): void | |
| 36 | + | { | |
| 37 | + | $roles = \admin_roles(['name' => 'SupportAccount']); | |
| 38 | + | $this->assertSame(['helpdesk'], $roles); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | public function testUnknownAccountGetsNoRoles(): void | |
| 42 | + | { | |
| 43 | + | $roles = \admin_roles(['name' => 'SomeoneElse']); | |
| 44 | + | $this->assertSame([], $roles); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | public function testNonArrayUserDataGetsNoRoles(): void | |
| 48 | + | { | |
| 49 | + | $this->assertSame([], \admin_roles(null)); | |
| 50 | + | $this->assertSame([], \admin_roles(false)); | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | public function testOthireIdentityIsTheAccountIdNotTheName(): void | |
| 54 | + | { | |
| 55 | + | $GLOBALS['config']['ServerEngine'] = 'OTHIRE'; | |
| 56 | + | $GLOBALS['config']['page_admin_access'] = [42]; | |
| 57 | + | ||
| 58 | + | $this->assertSame(['owner'], \admin_roles(['id' => 42, 'name' => 'OwnerAccount'])); | |
| 59 | + | $this->assertSame([], \admin_roles(['id' => 99, 'name' => 'OwnerAccount'])); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | public function testOwnerCanReachEveryModule(): void | |
| 63 | + | { | |
| 64 | + | $this->assertTrue(\acp_can_module('update', ['name' => 'OwnerAccount'])); | |
| 65 | + | $this->assertTrue(\acp_can_module('settings', ['name' => 'OwnerAccount'])); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | public function testAScopedAccountOnlyReachesItsGrantedModules(): void | |
| 69 | + | { | |
| 70 | + | $this->assertTrue(\acp_can_module('gallery', ['name' => 'ModeratorAccount'])); | |
| 71 | + | $this->assertFalse(\acp_can_module('accounts', ['name' => 'ModeratorAccount'])); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | public function testAScopedAccountAlwaysReachesDashboardAndSearch(): void | |
| 75 | + | { | |
| 76 | + | $this->assertTrue(\acp_can_module('dashboard', ['name' => 'ModeratorAccount'])); | |
| 77 | + | $this->assertTrue(\acp_can_module('search', ['name' => 'ModeratorAccount'])); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | public function testAnUngrantedAccountReachesNoModuleAtAll(): void | |
| 81 | + | { | |
| 82 | + | // 'update' was never granted to anyone in this fixture, so only the | |
| 83 | + | // owner bypass in acp_can_module() may reach it. | |
| 84 | + | $this->assertFalse(\acp_can_module('update', ['name' => 'ModeratorAccount'])); | |
| 85 | + | $this->assertFalse(\acp_can_module('update', ['name' => 'SupportAccount'])); | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | public function testAnonymousVisitorHasNoAccess(): void | |
| 89 | + | { | |
| 90 | + | $this->assertFalse(\acp_can_module('dashboard', null)); | |
| 91 | + | } | |
| 92 | + | } |
| @@ -0,0 +1,75 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | ||
| 9 | + | final class CsrfTest extends TestCase | |
| 10 | + | { | |
| 11 | + | protected function setUp(): void | |
| 12 | + | { | |
| 13 | + | $_SESSION = []; | |
| 14 | + | $_POST = []; | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | public function testTokenIsGeneratedOnFirstUse(): void | |
| 18 | + | { | |
| 19 | + | $this->assertArrayNotHasKey('acp_csrf', $_SESSION); | |
| 20 | + | $token = \acp_csrf(); | |
| 21 | + | $this->assertNotSame('', $token); | |
| 22 | + | $this->assertSame($token, $_SESSION['acp_csrf']); | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | public function testTokenIsStableAcrossCalls(): void | |
| 26 | + | { | |
| 27 | + | $first = \acp_csrf(); | |
| 28 | + | $second = \acp_csrf(); | |
| 29 | + | $this->assertSame($first, $second); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | public function testFieldEmbedsTheCurrentToken(): void | |
| 33 | + | { | |
| 34 | + | $token = \acp_csrf(); | |
| 35 | + | $field = \acp_csrf_field(); | |
| 36 | + | $this->assertStringContainsString('name="csrf_token"', $field); | |
| 37 | + | $this->assertStringContainsString(htmlspecialchars($token, ENT_QUOTES, 'UTF-8'), $field); | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | public function testVerifyRejectsMissingToken(): void | |
| 41 | + | { | |
| 42 | + | \acp_csrf(); | |
| 43 | + | $_POST = []; | |
| 44 | + | $this->assertFalse(\acp_verify_csrf()); | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | public function testVerifyRejectsWrongToken(): void | |
| 48 | + | { | |
| 49 | + | \acp_csrf(); | |
| 50 | + | $_POST['csrf_token'] = 'attacker-supplied-value'; | |
| 51 | + | $this->assertFalse(\acp_verify_csrf()); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | public function testVerifyRejectsNonStringToken(): void | |
| 55 | + | { | |
| 56 | + | \acp_csrf(); | |
| 57 | + | $_POST['csrf_token'] = ['not', 'a', 'string']; | |
| 58 | + | $this->assertFalse(\acp_verify_csrf()); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | public function testVerifyAcceptsTheRealToken(): void | |
| 62 | + | { | |
| 63 | + | $token = \acp_csrf(); | |
| 64 | + | $_POST['csrf_token'] = $token; | |
| 65 | + | $this->assertTrue(\acp_verify_csrf()); | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | public function testEachSessionGetsAnIndependentToken(): void | |
| 69 | + | { | |
| 70 | + | $tokenA = \acp_csrf(); | |
| 71 | + | $_SESSION = []; | |
| 72 | + | $tokenB = \acp_csrf(); | |
| 73 | + | $this->assertNotSame($tokenA, $tokenB); | |
| 74 | + | } | |
| 75 | + | } |
| @@ -0,0 +1,50 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | ||
| 9 | + | final class GuildLogoSafeNameTest extends TestCase | |
| 10 | + | { | |
| 11 | + | public function testAcceptsAnOrdinaryGuildName(): void | |
| 12 | + | { | |
| 13 | + | $this->assertSame('Knights of ZnoteX', \guild_logo_safe_name('Knights of ZnoteX')); | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | public function testTrimsWhitespace(): void | |
| 17 | + | { | |
| 18 | + | $this->assertSame('Guardians', \guild_logo_safe_name(' Guardians ')); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | public function testRejectsEmptyName(): void | |
| 22 | + | { | |
| 23 | + | $this->assertNull(\guild_logo_safe_name('')); | |
| 24 | + | $this->assertNull(\guild_logo_safe_name(' ')); | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | public function testRejectsNameLongerThanSixtyCharacters(): void | |
| 28 | + | { | |
| 29 | + | $this->assertNull(\guild_logo_safe_name(str_repeat('a', 61))); | |
| 30 | + | $this->assertNotNull(\guild_logo_safe_name(str_repeat('a', 60))); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | /** @dataProvider pathTraversalProvider */ | |
| 34 | + | public function testRejectsPathTraversalAttempts(string $malicious): void | |
| 35 | + | { | |
| 36 | + | $this->assertNull(\guild_logo_safe_name($malicious)); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | public static function pathTraversalProvider(): array | |
| 40 | + | { | |
| 41 | + | return [ | |
| 42 | + | 'parent directory' => ['../../../etc/passwd'], | |
| 43 | + | 'dot dot in the middle' => ['guild..name'], | |
| 44 | + | 'forward slash' => ['guild/name'], | |
| 45 | + | 'backslash' => ['guild\\name'], | |
| 46 | + | 'null byte' => ["guild\0name"], | |
| 47 | + | 'windows traversal' => ['..\\..\\config.local.php'], | |
| 48 | + | ]; | |
| 49 | + | } | |
| 50 | + | } |
| @@ -0,0 +1,105 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | ||
| 9 | + | final class MigrationSqlSplitterTest extends TestCase | |
| 10 | + | { | |
| 11 | + | public function testSplitsSimpleStatements(): void | |
| 12 | + | { | |
| 13 | + | $sql = "CREATE TABLE a (id INT);\nINSERT INTO a VALUES (1);"; | |
| 14 | + | $this->assertSame( | |
| 15 | + | ['CREATE TABLE a (id INT)', 'INSERT INTO a VALUES (1)'], | |
| 16 | + | \znote_migration_split_sql($sql) | |
| 17 | + | ); | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | public function testSemicolonInsideAStringLiteralDoesNotSplit(): void | |
| 21 | + | { | |
| 22 | + | $sql = "INSERT INTO a (name) VALUES ('it;s here');"; | |
| 23 | + | $statements = \znote_migration_split_sql($sql); | |
| 24 | + | $this->assertCount(1, $statements); | |
| 25 | + | $this->assertStringContainsString("'it;s here'", $statements[0]); | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | public function testSemicolonInsideALineCommentDoesNotSplit(): void | |
| 29 | + | { | |
| 30 | + | // The comment has no terminating semicolon of its own, so it attaches | |
| 31 | + | // to the following statement as a single chunk. | |
| 32 | + | $sql = "-- comment ; still comment\nINSERT INTO a VALUES (1);"; | |
| 33 | + | $statements = \znote_migration_split_sql($sql); | |
| 34 | + | $this->assertCount(1, $statements); | |
| 35 | + | $this->assertStringContainsString('INSERT INTO a VALUES (1)', $statements[0]); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | public function testSemicolonInsideABlockCommentDoesNotSplit(): void | |
| 39 | + | { | |
| 40 | + | $sql = "/* a ; block ; comment */ INSERT INTO a VALUES (1);"; | |
| 41 | + | $statements = \znote_migration_split_sql($sql); | |
| 42 | + | $this->assertCount(1, $statements); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | public function testEscapedQuoteInsideAStringDoesNotCloseIt(): void | |
| 46 | + | { | |
| 47 | + | $sql = "INSERT INTO a (name) VALUES ('it\\'s a trap; still one statement');"; | |
| 48 | + | $statements = \znote_migration_split_sql($sql); | |
| 49 | + | $this->assertCount(1, $statements); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | public function testHashStartsALineComment(): void | |
| 53 | + | { | |
| 54 | + | $sql = "# hash comment ; here\nINSERT INTO a VALUES (2);"; | |
| 55 | + | $statements = \znote_migration_split_sql($sql); | |
| 56 | + | $this->assertCount(1, $statements); | |
| 57 | + | $this->assertStringContainsString('INSERT INTO a VALUES (2)', $statements[0]); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | public function testEmptyStatementsAreDropped(): void | |
| 61 | + | { | |
| 62 | + | $sql = "INSERT INTO a VALUES (1);;; ;\nINSERT INTO a VALUES (2);"; | |
| 63 | + | $statements = \znote_migration_split_sql($sql); | |
| 64 | + | $this->assertCount(2, $statements); | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | public function testTrailingStatementWithoutASemicolonIsKept(): void | |
| 68 | + | { | |
| 69 | + | $sql = "INSERT INTO a VALUES (1);\nINSERT INTO a VALUES (2)"; | |
| 70 | + | $statements = \znote_migration_split_sql($sql); | |
| 71 | + | $this->assertCount(2, $statements); | |
| 72 | + | $this->assertSame('INSERT INTO a VALUES (2)', $statements[1]); | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | public function testDestructiveStatementsAreRejectedByTheUpdaterSafetyCheck(): void | |
| 76 | + | { | |
| 77 | + | $this->assertFalse(\znote_update_migration_safe('DROP TABLE accounts;')); | |
| 78 | + | $this->assertFalse(\znote_update_migration_safe('DELETE FROM accounts;')); | |
| 79 | + | $this->assertFalse(\znote_update_migration_safe('UPDATE accounts SET password = \'\';')); | |
| 80 | + | $this->assertFalse(\znote_update_migration_safe('TRUNCATE accounts;')); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | public function testAdditiveStatementsAreAcceptedByTheUpdaterSafetyCheck(): void | |
| 84 | + | { | |
| 85 | + | $this->assertTrue(\znote_update_migration_safe('CREATE TABLE IF NOT EXISTS foo (id INT);')); | |
| 86 | + | $this->assertTrue(\znote_update_migration_safe('ALTER TABLE foo ADD COLUMN bar INT;')); | |
| 87 | + | $this->assertTrue(\znote_update_migration_safe("INSERT IGNORE INTO foo (id) VALUES (1);")); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | public function testAMixOfSafeAndDestructiveStatementsIsRejected(): void | |
| 91 | + | { | |
| 92 | + | $sql = "CREATE TABLE IF NOT EXISTS foo (id INT);\nDROP TABLE bar;"; | |
| 93 | + | $this->assertFalse(\znote_update_migration_safe($sql)); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | public function testDestructiveKeywordHiddenInAStringLiteralIsStillCaughtByTheRegexScan(): void | |
| 97 | + | { | |
| 98 | + | // znote_update_migration_safe() only strips comments, not string | |
| 99 | + | // contents, so a DROP/DELETE keyword anywhere in the statement - | |
| 100 | + | // even inside a quoted value - is treated as unsafe. This is a | |
| 101 | + | // deliberately conservative false positive, not a bypass. | |
| 102 | + | $sql = "INSERT IGNORE INTO foo (note) VALUES ('please DROP by later');"; | |
| 103 | + | $this->assertFalse(\znote_update_migration_safe($sql)); | |
| 104 | + | } | |
| 105 | + | } |
| @@ -0,0 +1,177 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | ||
| 9 | + | final class PaymentWebhookTest extends TestCase | |
| 10 | + | { | |
| 11 | + | private const SECRET = 'whsec_test_only_1234567890'; | |
| 12 | + | ||
| 13 | + | protected function setUp(): void | |
| 14 | + | { | |
| 15 | + | $GLOBALS['config'] = [ | |
| 16 | + | 'stripe' => ['webhook_secret' => self::SECRET], | |
| 17 | + | 'mercadopago' => ['webhook_secret' => self::SECRET], | |
| 18 | + | ]; | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | public function testGenuineStripeSignatureIsAccepted(): void | |
| 22 | + | { | |
| 23 | + | $payload = '{"id":"evt_1","type":"checkout.session.completed"}'; | |
| 24 | + | $header = $this->realStripeHeader($payload); | |
| 25 | + | ||
| 26 | + | $this->assertTrue(\payment_gateway_verify_stripe_signature($payload, $header)); | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | public function testForgedStripeSignatureIsRejected(): void | |
| 30 | + | { | |
| 31 | + | $payload = '{"id":"evt_1","type":"checkout.session.completed"}'; | |
| 32 | + | $timestamp = time(); | |
| 33 | + | $forgedSignature = hash_hmac('sha256', $timestamp . '.' . $payload, 'attacker-does-not-know-the-real-secret'); | |
| 34 | + | $header = "t={$timestamp},v1={$forgedSignature}"; | |
| 35 | + | ||
| 36 | + | $this->assertFalse(\payment_gateway_verify_stripe_signature($payload, $header)); | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | public function testTamperedPayloadInvalidatesAGenuineSignature(): void | |
| 40 | + | { | |
| 41 | + | $originalPayload = '{"id":"evt_1","amount_total":100}'; | |
| 42 | + | $header = $this->realStripeHeader($originalPayload); | |
| 43 | + | ||
| 44 | + | $tamperedPayload = '{"id":"evt_1","amount_total":999999}'; | |
| 45 | + | $this->assertFalse(\payment_gateway_verify_stripe_signature($tamperedPayload, $header)); | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | public function testReplayedOldSignatureIsRejected(): void | |
| 49 | + | { | |
| 50 | + | $payload = '{"id":"evt_1"}'; | |
| 51 | + | $oldTimestamp = time() - 3600; | |
| 52 | + | $signature = hash_hmac('sha256', $oldTimestamp . '.' . $payload, self::SECRET); | |
| 53 | + | $header = "t={$oldTimestamp},v1={$signature}"; | |
| 54 | + | ||
| 55 | + | $this->assertFalse(\payment_gateway_verify_stripe_signature($payload, $header)); | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | public function testMissingHeaderIsRejected(): void | |
| 59 | + | { | |
| 60 | + | $this->assertFalse(\payment_gateway_verify_stripe_signature('{}', '')); | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | public function testEmptyConfiguredSecretRejectsEverything(): void | |
| 64 | + | { | |
| 65 | + | $GLOBALS['config']['stripe']['webhook_secret'] = ''; | |
| 66 | + | $payload = '{"id":"evt_1"}'; | |
| 67 | + | $header = $this->realStripeHeader($payload, 'whatever'); | |
| 68 | + | ||
| 69 | + | $this->assertFalse(\payment_gateway_verify_stripe_signature($payload, $header)); | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | public function testGenuineMercadopagoSignatureIsAccepted(): void | |
| 73 | + | { | |
| 74 | + | $dataId = '123456'; | |
| 75 | + | $requestId = 'req-1'; | |
| 76 | + | $header = $this->realMercadopagoHeader($dataId, $requestId, $timestamp = (string)time()); | |
| 77 | + | ||
| 78 | + | $this->assertTrue(\payment_gateway_verify_mercadopago_signature($dataId, $requestId, $header)); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | public function testForgedMercadopagoSignatureIsRejected(): void | |
| 82 | + | { | |
| 83 | + | $dataId = '123456'; | |
| 84 | + | $requestId = 'req-1'; | |
| 85 | + | $timestamp = (string)time(); | |
| 86 | + | $manifest = 'id:' . $dataId . ';request-id:' . $requestId . ';ts:' . $timestamp . ';'; | |
| 87 | + | $forged = hash_hmac('sha256', $manifest, 'not-the-real-secret'); | |
| 88 | + | $header = "ts={$timestamp},v1={$forged}"; | |
| 89 | + | ||
| 90 | + | $this->assertFalse(\payment_gateway_verify_mercadopago_signature($dataId, $requestId, $header)); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | public function testMercadopagoSignatureBoundToTheWrongDataIdIsRejected(): void | |
| 94 | + | { | |
| 95 | + | $requestId = 'req-1'; | |
| 96 | + | $timestamp = (string)time(); | |
| 97 | + | $header = $this->realMercadopagoHeader('legit-payment-id', $requestId, $timestamp); | |
| 98 | + | ||
| 99 | + | // An attacker who intercepted a genuine notification for one payment | |
| 100 | + | // cannot replay it against a different data id. | |
| 101 | + | $this->assertFalse(\payment_gateway_verify_mercadopago_signature('someone-elses-payment-id', $requestId, $header)); | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | public function testAlreadyCreditedTransactionIsNeverCreditedTwice(): void | |
| 105 | + | { | |
| 106 | + | $tx = ['credited' => 1, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD']; | |
| 107 | + | $this->assertSame('already_credited', \payment_gateway_validate_transaction('stripe', $tx, '', [])); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | public function testMissingTransactionIsRejected(): void | |
| 111 | + | { | |
| 112 | + | $this->assertSame('missing_transaction', \payment_gateway_validate_transaction('stripe', false, '', [])); | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | public function testProviderReferenceCannotBeSwappedAfterTheFactForStripe(): void | |
| 116 | + | { | |
| 117 | + | $tx = [ | |
| 118 | + | 'credited' => 0, | |
| 119 | + | 'provider_reference' => 'pi_original_genuine_payment_intent', | |
| 120 | + | 'account_id' => 5, | |
| 121 | + | 'points' => 100, | |
| 122 | + | 'price' => 9.99, | |
| 123 | + | 'currency' => 'USD', | |
| 124 | + | ]; | |
| 125 | + | ||
| 126 | + | $result = \payment_gateway_validate_transaction('stripe', $tx, 'pi_attacker_supplied_different_intent', []); | |
| 127 | + | $this->assertSame('provider_reference_mismatch', $result); | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | public function testInvalidAccountOrPointsIsRejected(): void | |
| 131 | + | { | |
| 132 | + | $tx = ['credited' => 0, 'account_id' => 0, 'points' => 100, 'price' => 9.99, 'currency' => 'USD']; | |
| 133 | + | $this->assertSame('invalid_transaction', \payment_gateway_validate_transaction('mercadopago', $tx, '', [])); | |
| 134 | + | ||
| 135 | + | $tx['account_id'] = 5; | |
| 136 | + | $tx['points'] = 0; | |
| 137 | + | $this->assertSame('invalid_transaction', \payment_gateway_validate_transaction('mercadopago', $tx, '', [])); | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | public function testAmountMismatchBlocksCrediting(): void | |
| 141 | + | { | |
| 142 | + | $tx = ['credited' => 0, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD']; | |
| 143 | + | $payload = ['transaction_amount' => 0.01, 'currency_id' => 'usd']; | |
| 144 | + | ||
| 145 | + | $this->assertSame('amount_mismatch', \payment_gateway_validate_transaction('mercadopago', $tx, '', $payload)); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | public function testLiveModeCannotCreditATestModeTransactionOrViceVersa(): void | |
| 149 | + | { | |
| 150 | + | $tx = ['credited' => 0, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD', 'test_mode' => 1]; | |
| 151 | + | $payload = ['transaction_amount' => 9.99, 'currency_id' => 'usd', 'live_mode' => true]; | |
| 152 | + | ||
| 153 | + | $this->assertSame('mode_mismatch', \payment_gateway_validate_transaction('mercadopago', $tx, '', $payload)); | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | public function testAFullyValidTransactionPassesValidation(): void | |
| 157 | + | { | |
| 158 | + | $tx = ['credited' => 0, 'account_id' => 5, 'points' => 100, 'price' => 9.99, 'currency' => 'USD', 'test_mode' => 0]; | |
| 159 | + | $payload = ['transaction_amount' => 9.99, 'currency_id' => 'usd', 'live_mode' => true]; | |
| 160 | + | ||
| 161 | + | $this->assertSame('ok', \payment_gateway_validate_transaction('mercadopago', $tx, '', $payload)); | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | private function realStripeHeader(string $payload, ?string $secret = null): string | |
| 165 | + | { | |
| 166 | + | $timestamp = time(); | |
| 167 | + | $signature = hash_hmac('sha256', $timestamp . '.' . $payload, $secret ?? self::SECRET); | |
| 168 | + | return "t={$timestamp},v1={$signature}"; | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | private function realMercadopagoHeader(string $dataId, string $requestId, string $timestamp): string | |
| 172 | + | { | |
| 173 | + | $manifest = 'id:' . $dataId . ';request-id:' . $requestId . ';ts:' . $timestamp . ';'; | |
| 174 | + | $signature = hash_hmac('sha256', $manifest, self::SECRET); | |
| 175 | + | return "ts={$timestamp},v1={$signature}"; | |
| 176 | + | } | |
| 177 | + | } |
| @@ -0,0 +1,85 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | ||
| 9 | + | final class PluginSanitizeTest extends TestCase | |
| 10 | + | { | |
| 11 | + | public function testAcceptsAnOrdinaryPluginName(): void | |
| 12 | + | { | |
| 13 | + | $this->assertSame('boosted_creatures', \znote_plugin_sanitize('boosted_creatures')); | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | public function testLowercasesAndTrims(): void | |
| 17 | + | { | |
| 18 | + | $this->assertSame('shop-coupons', \znote_plugin_sanitize(' Shop-Coupons ')); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | /** @dataProvider pathTraversalProvider */ | |
| 22 | + | public function testRejectsPathTraversalAttempts(string $malicious): void | |
| 23 | + | { | |
| 24 | + | $this->assertSame('', \znote_plugin_sanitize($malicious)); | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | public static function pathTraversalProvider(): array | |
| 28 | + | { | |
| 29 | + | return [ | |
| 30 | + | 'parent directory' => ['../../../etc/passwd'], | |
| 31 | + | 'nested traversal' => ['plugins/../../config.local.php'], | |
| 32 | + | 'absolute unix path' => ['/etc/passwd'], | |
| 33 | + | 'absolute windows path' => ['C:\\Windows\\System32'], | |
| 34 | + | 'null byte' => ["plugin\0.php"], | |
| 35 | + | 'slash' => ['foo/bar'], | |
| 36 | + | 'backslash' => ['foo\\bar'], | |
| 37 | + | 'empty string' => [''], | |
| 38 | + | 'only dots' => ['..'], | |
| 39 | + | 'too long' => [str_repeat('a', 65)], | |
| 40 | + | ]; | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | public function testExtensionApiRejectsAnInvalidPluginName(): void | |
| 44 | + | { | |
| 45 | + | $this->expectException(\InvalidArgumentException::class); | |
| 46 | + | \ZnoteExtensionApi::plugin('../../../etc/passwd'); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | public function testExtensionApiAcceptsAValidPluginName(): void | |
| 50 | + | { | |
| 51 | + | $api = \ZnoteExtensionApi::plugin('boosted_creatures'); | |
| 52 | + | $this->assertSame('boosted_creatures', $api->name()); | |
| 53 | + | $this->assertSame('plugin', $api->kind()); | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | /** @dataProvider relativePathTraversalProvider */ | |
| 57 | + | public function testRelativePathHelperRejectsTraversal(string $malicious): void | |
| 58 | + | { | |
| 59 | + | $this->assertSame('', \znote_extension_relative_path($malicious)); | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | public static function relativePathTraversalProvider(): array | |
| 63 | + | { | |
| 64 | + | return [ | |
| 65 | + | 'parent directory' => ['../../../etc/passwd'], | |
| 66 | + | 'nested traversal' => ['css/../../config.local.php'], | |
| 67 | + | 'absolute windows path' => ['C:\\Windows\\System32'], | |
| 68 | + | 'null byte' => ["css/style\0.css"], | |
| 69 | + | 'only dots' => ['..'], | |
| 70 | + | 'single dot segment' => ['css/./style.css'], | |
| 71 | + | 'empty string' => [''], | |
| 72 | + | ]; | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | public function testRelativePathHelperAcceptsAnOrdinaryAssetPath(): void | |
| 76 | + | { | |
| 77 | + | $this->assertSame('css/style.css', \znote_extension_relative_path('css/style.css')); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | public function testRelativePathHelperNormalisesBackslashesAndLeadingSlash(): void | |
| 81 | + | { | |
| 82 | + | $this->assertSame('css/style.css', \znote_extension_relative_path('\\css\\style.css')); | |
| 83 | + | $this->assertSame('css/style.css', \znote_extension_relative_path('/css/style.css/')); | |
| 84 | + | } | |
| 85 | + | } |
| @@ -0,0 +1,150 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | ||
| 9 | + | require_once dirname(__DIR__, 2) . '/engine/function/plugin_settings.php'; | |
| 10 | + | ||
| 11 | + | final class PluginSettingsTest extends TestCase | |
| 12 | + | { | |
| 13 | + | private const TEST_PLUGIN = 'zztest_settings_plugin'; | |
| 14 | + | ||
| 15 | + | private function pluginDir(): string { | |
| 16 | + | return ZNOTE_PLUGIN_DIR . '/' . self::TEST_PLUGIN; | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | protected function tearDown(): void | |
| 20 | + | { | |
| 21 | + | $dir = $this->pluginDir(); | |
| 22 | + | if (is_file($dir . '/settings.json')) { | |
| 23 | + | unlink($dir . '/settings.json'); | |
| 24 | + | } | |
| 25 | + | if (is_dir($dir)) { | |
| 26 | + | rmdir($dir); | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | private function writeSchema(array $data): void | |
| 31 | + | { | |
| 32 | + | mkdir($this->pluginDir(), 0775, true); | |
| 33 | + | file_put_contents($this->pluginDir() . '/settings.json', json_encode($data)); | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | public function testHasIsFalseWithoutAFile(): void | |
| 37 | + | { | |
| 38 | + | $this->assertFalse(\znote_plugin_settings_has(self::TEST_PLUGIN)); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | public function testSchemaIsEmptyForAnUnknownPlugin(): void | |
| 42 | + | { | |
| 43 | + | $this->assertSame([], \znote_plugin_settings_schema('../../../etc/passwd')); | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | public function testSchemaParsesValidFields(): void | |
| 47 | + | { | |
| 48 | + | $this->writeSchema(['fields' => [ | |
| 49 | + | ['key' => 'api_key', 'type' => 'text', 'label' => 'API key', 'default' => ''], | |
| 50 | + | ['key' => 'enabled', 'type' => 'bool', 'default' => '1'], | |
| 51 | + | ['key' => 'mode', 'type' => 'select', 'default' => 'test', 'options' => ['test' => 'Test', 'live' => 'Live']], | |
| 52 | + | ['key' => 'max_items', 'type' => 'int', 'default' => '10', 'min' => 1, 'max' => 100], | |
| 53 | + | ]]); | |
| 54 | + | ||
| 55 | + | $schema = \znote_plugin_settings_schema(self::TEST_PLUGIN); | |
| 56 | + | ||
| 57 | + | $this->assertTrue(\znote_plugin_settings_has(self::TEST_PLUGIN)); | |
| 58 | + | $this->assertSame(['api_key', 'enabled', 'mode', 'max_items'], array_keys($schema)); | |
| 59 | + | $this->assertSame('API key', $schema['api_key']['label']); | |
| 60 | + | $this->assertSame(1, $schema['max_items']['min']); | |
| 61 | + | $this->assertSame(100, $schema['max_items']['max']); | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | public function testSchemaDropsFieldsWithAnUnknownType(): void | |
| 65 | + | { | |
| 66 | + | $this->writeSchema(['fields' => [ | |
| 67 | + | ['key' => 'good', 'type' => 'text'], | |
| 68 | + | ['key' => 'bad', 'type' => 'not_a_real_type'], | |
| 69 | + | ]]); | |
| 70 | + | ||
| 71 | + | $schema = \znote_plugin_settings_schema(self::TEST_PLUGIN); | |
| 72 | + | ||
| 73 | + | $this->assertArrayHasKey('good', $schema); | |
| 74 | + | $this->assertArrayNotHasKey('bad', $schema); | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | public function testSchemaDropsFieldsWithAnInvalidKey(): void | |
| 78 | + | { | |
| 79 | + | $this->writeSchema(['fields' => [ | |
| 80 | + | ['key' => 'ok_key', 'type' => 'text'], | |
| 81 | + | ['key' => 'has spaces', 'type' => 'text'], | |
| 82 | + | ['key' => '../traversal', 'type' => 'text'], | |
| 83 | + | ['key' => '', 'type' => 'text'], | |
| 84 | + | ]]); | |
| 85 | + | ||
| 86 | + | $schema = \znote_plugin_settings_schema(self::TEST_PLUGIN); | |
| 87 | + | ||
| 88 | + | $this->assertSame(['ok_key'], array_keys($schema)); | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | public function testMalformedJsonYieldsAnEmptySchema(): void | |
| 92 | + | { | |
| 93 | + | mkdir($this->pluginDir(), 0775, true); | |
| 94 | + | file_put_contents($this->pluginDir() . '/settings.json', '{not valid json'); | |
| 95 | + | ||
| 96 | + | $this->assertSame([], \znote_plugin_settings_schema(self::TEST_PLUGIN)); | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | public function testStorageKeyMatchesTheExtensionApiNamespace(): void | |
| 100 | + | { | |
| 101 | + | $this->assertSame( | |
| 102 | + | 'plugin:my_plugin:setting:api_key', | |
| 103 | + | \znote_plugin_settings_storage_key('my_plugin', 'api_key') | |
| 104 | + | ); | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | public function testSanitizeBoolCoercesAnyTruthyInputToOneOrZero(): void | |
| 108 | + | { | |
| 109 | + | $field = ['type' => 'bool']; | |
| 110 | + | $this->assertSame('1', \znote_plugin_settings_sanitize_field($field, '1')); | |
| 111 | + | $this->assertSame('1', \znote_plugin_settings_sanitize_field($field, 'on')); | |
| 112 | + | $this->assertSame('0', \znote_plugin_settings_sanitize_field($field, null)); | |
| 113 | + | $this->assertSame('0', \znote_plugin_settings_sanitize_field($field, '0')); | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | public function testSanitizeIntRejectsNonNumericInput(): void | |
| 117 | + | { | |
| 118 | + | $field = ['type' => 'int', 'min' => null, 'max' => null]; | |
| 119 | + | $this->assertNull(\znote_plugin_settings_sanitize_field($field, 'not a number')); | |
| 120 | + | $this->assertNull(\znote_plugin_settings_sanitize_field($field, '12; DROP TABLE accounts')); | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | public function testSanitizeIntClampsToMinAndMax(): void | |
| 124 | + | { | |
| 125 | + | $field = ['type' => 'int', 'min' => 1, 'max' => 10]; | |
| 126 | + | $this->assertSame('1', \znote_plugin_settings_sanitize_field($field, '-5')); | |
| 127 | + | $this->assertSame('10', \znote_plugin_settings_sanitize_field($field, '500')); | |
| 128 | + | $this->assertSame('5', \znote_plugin_settings_sanitize_field($field, '5')); | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | public function testSanitizeSelectRejectsAValueOutsideItsOptions(): void | |
| 132 | + | { | |
| 133 | + | $field = ['type' => 'select', 'options' => ['a' => 'A', 'b' => 'B']]; | |
| 134 | + | $this->assertSame('a', \znote_plugin_settings_sanitize_field($field, 'a')); | |
| 135 | + | $this->assertNull(\znote_plugin_settings_sanitize_field($field, 'injected')); | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | public function testSanitizeChecklistKeepsOnlyKnownOptions(): void | |
| 139 | + | { | |
| 140 | + | $field = ['type' => 'checklist', 'options' => ['a' => 'A', 'b' => 'B', 'c' => 'C']]; | |
| 141 | + | $this->assertSame('a,c', \znote_plugin_settings_sanitize_field($field, ['a', 'c', 'not_an_option'])); | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | public function testSanitizeTextRejectsNonScalarInput(): void | |
| 145 | + | { | |
| 146 | + | $field = ['type' => 'text']; | |
| 147 | + | $this->assertNull(\znote_plugin_settings_sanitize_field($field, ['array', 'not', 'scalar'])); | |
| 148 | + | $this->assertSame('hello', \znote_plugin_settings_sanitize_field($field, 'hello')); | |
| 149 | + | } | |
| 150 | + | } |
| @@ -0,0 +1,107 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | ||
| 9 | + | require_once dirname(__DIR__, 2) . '/engine/adapter/ServerAdapterInterface.php'; | |
| 10 | + | require_once dirname(__DIR__, 2) . '/engine/adapter/TFSAdapter.php'; | |
| 11 | + | require_once dirname(__DIR__, 2) . '/engine/adapter/CanaryAdapter.php'; | |
| 12 | + | require_once dirname(__DIR__, 2) . '/engine/adapter/OtHireAdapter.php'; | |
| 13 | + | require_once dirname(__DIR__, 2) . '/engine/adapter/BlackTekAdapter.php'; | |
| 14 | + | require_once dirname(__DIR__, 2) . '/engine/adapter/factory.php'; | |
| 15 | + | ||
| 16 | + | final class ServerAdapterTest extends TestCase | |
| 17 | + | { | |
| 18 | + | protected function setUp(): void | |
| 19 | + | { | |
| 20 | + | $GLOBALS['config'] = [ | |
| 21 | + | 'ServerEngine' => 'TFS_10', | |
| 22 | + | 'ServerEngineReal' => 'TFS_10', | |
| 23 | + | ]; | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | /** @dataProvider engineProvider */ | |
| 27 | + | public function testFactoryPicksTheRightAdapterClass(string $engine, string $expectedClass): void | |
| 28 | + | { | |
| 29 | + | $adapter = \znote_server_adapter($engine); | |
| 30 | + | $this->assertInstanceOf($expectedClass, $adapter); | |
| 31 | + | $this->assertSame($engine, $adapter->key()); | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | public static function engineProvider(): array | |
| 35 | + | { | |
| 36 | + | return [ | |
| 37 | + | 'TFS_02' => ['TFS_02', \TFSAdapter::class], | |
| 38 | + | 'TFS_03' => ['TFS_03', \TFSAdapter::class], | |
| 39 | + | 'TFS_10' => ['TFS_10', \TFSAdapter::class], | |
| 40 | + | 'TFS_16' => ['TFS_16', \TFSAdapter::class], | |
| 41 | + | 'OTHIRE' => ['OTHIRE', \OtHireAdapter::class], | |
| 42 | + | 'CANARY' => ['CANARY', \CanaryAdapter::class], | |
| 43 | + | 'BLACKTEK' => ['BLACKTEK', \BlackTekAdapter::class], | |
| 44 | + | ]; | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | public function testFactoryFallsBackToGlobalConfigWhenNoEngineIsGiven(): void | |
| 48 | + | { | |
| 49 | + | $GLOBALS['config']['ServerEngineReal'] = 'OTHIRE'; | |
| 50 | + | $adapter = \znote_server_adapter(); | |
| 51 | + | $this->assertInstanceOf(\OtHireAdapter::class, $adapter); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | public function testFactoryCachesOneInstancePerEngine(): void | |
| 55 | + | { | |
| 56 | + | $first = \znote_server_adapter('TFS_10'); | |
| 57 | + | $second = \znote_server_adapter('TFS_10'); | |
| 58 | + | $this->assertSame($first, $second); | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | public function testLegacyTwoFactorSupportMatchesWhichEnginesHaveAccountsSecret(): void | |
| 62 | + | { | |
| 63 | + | // TFS_10, TFS_16 (normalised to TFS_10) and BlackTek all ship an | |
| 64 | + | // accounts.secret column; TFS_02, TFS_03, Canary and otHire do not. | |
| 65 | + | $this->assertTrue((new \TFSAdapter('TFS_10'))->supportsLegacyTwoFactor()); | |
| 66 | + | $this->assertFalse((new \TFSAdapter('TFS_02'))->supportsLegacyTwoFactor()); | |
| 67 | + | $this->assertFalse((new \TFSAdapter('TFS_03'))->supportsLegacyTwoFactor()); | |
| 68 | + | $this->assertFalse((new \TFSAdapter('TFS_16'))->supportsLegacyTwoFactor()); | |
| 69 | + | $this->assertFalse((new \CanaryAdapter())->supportsLegacyTwoFactor()); | |
| 70 | + | $this->assertFalse((new \OtHireAdapter())->supportsLegacyTwoFactor()); | |
| 71 | + | $this->assertTrue((new \BlackTekAdapter())->supportsLegacyTwoFactor()); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | public function testOthireIdentifiesAccountsByIdNotName(): void | |
| 75 | + | { | |
| 76 | + | $adapter = new \OtHireAdapter(); | |
| 77 | + | $this->assertSame('id', $adapter->accountIdentityColumn()); | |
| 78 | + | $this->assertSame('`a`.`id`', $adapter->accountDisplayColumn()); | |
| 79 | + | } | |
| 80 | + | ||
| 81 | + | /** @dataProvider normalizedEngineProvider */ | |
| 82 | + | public function testNormalizedEngineMatchesTheSchemaAnAdapterActuallyRuns(string $realEngine, string $expected): void | |
| 83 | + | { | |
| 84 | + | $this->assertSame($expected, \znote_server_adapter($realEngine)->normalizedEngine()); | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | public static function normalizedEngineProvider(): array | |
| 88 | + | { | |
| 89 | + | return [ | |
| 90 | + | 'TFS_02 stays TFS_02' => ['TFS_02', 'TFS_02'], | |
| 91 | + | 'TFS_03 stays TFS_03' => ['TFS_03', 'TFS_03'], | |
| 92 | + | 'TFS_10 stays TFS_10' => ['TFS_10', 'TFS_10'], | |
| 93 | + | 'TFS_16 normalises to TFS_10' => ['TFS_16', 'TFS_10'], | |
| 94 | + | 'Canary normalises to TFS_10' => ['CANARY', 'TFS_10'], | |
| 95 | + | 'BlackTek normalises to TFS_10' => ['BLACKTEK', 'TFS_10'], | |
| 96 | + | 'otHire stays OTHIRE' => ['OTHIRE', 'OTHIRE'], | |
| 97 | + | ]; | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | public function testEveryOtherAdapterIdentifiesAccountsByName(): void | |
| 101 | + | { | |
| 102 | + | foreach ([new \TFSAdapter('TFS_10'), new \CanaryAdapter(), new \BlackTekAdapter()] as $adapter) { | |
| 103 | + | $this->assertSame('name', $adapter->accountIdentityColumn()); | |
| 104 | + | $this->assertSame('`a`.`name`', $adapter->accountDisplayColumn()); | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } |
| @@ -0,0 +1,65 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | ||
| 9 | + | require_once dirname(__DIR__, 2) . '/engine/function/rfc6238.php'; | |
| 10 | + | ||
| 11 | + | final class TotpCompatibilityTest extends TestCase | |
| 12 | + | { | |
| 13 | + | public function testBase32DecodesRfc4648Vectors(): void | |
| 14 | + | { | |
| 15 | + | $vectors = [ | |
| 16 | + | 'MY======' => 'f', | |
| 17 | + | 'MZXQ====' => 'fo', | |
| 18 | + | 'MZXW6===' => 'foo', | |
| 19 | + | 'MZXW6YQ=' => 'foob', | |
| 20 | + | 'MZXW6YTB' => 'fooba', | |
| 21 | + | 'MZXW6YTBOI======' => 'foobar', | |
| 22 | + | ]; | |
| 23 | + | ||
| 24 | + | foreach ($vectors as $encoded => $plain) { | |
| 25 | + | $this->assertSame($plain, \Base32Static::decode($encoded)); | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | public function testTwentyCharacterUnpaddedAuthenticatorSecretRoundTrips(): void | |
| 30 | + | { | |
| 31 | + | $plain = 'Hello World!'; | |
| 32 | + | $secret = \Base32Static::encode($plain, false); | |
| 33 | + | ||
| 34 | + | $this->assertSame(20, strlen($secret)); | |
| 35 | + | $this->assertSame($plain, \Base32Static::decode($secret)); | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | public function testLowercaseUnpaddedSecretsAreAccepted(): void | |
| 39 | + | { | |
| 40 | + | $this->assertSame('Hello World!', \Base32Static::decode('jbswy3dpeblw64tmmqqq')); | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | public function testMalformedBase32IsRejected(): void | |
| 44 | + | { | |
| 45 | + | $this->assertFalse(\Base32Static::decode('INVALID-SECRET')); | |
| 46 | + | $this->assertFalse(\Base32Static::decode('M=ZXW6===')); | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | public function testAuthenticatorUriDeclaresPortableTotpParameters(): void | |
| 50 | + | { | |
| 51 | + | $url = \TokenAuth6238::getBarCodeUrl('account', 'localhost', 'JBSWY3DPEHPK3PXP', 'ZnoteX'); | |
| 52 | + | parse_str((string)parse_url($url, PHP_URL_QUERY), $qrQuery); | |
| 53 | + | $this->assertArrayHasKey('data', $qrQuery); | |
| 54 | + | ||
| 55 | + | $otpauth = (string)$qrQuery['data']; | |
| 56 | + | $this->assertStringStartsWith('otpauth://totp/account%40localhost?', $otpauth); | |
| 57 | + | parse_str((string)parse_url($otpauth, PHP_URL_QUERY), $totpQuery); | |
| 58 | + | ||
| 59 | + | $this->assertSame('JBSWY3DPEHPK3PXP', $totpQuery['secret'] ?? null); | |
| 60 | + | $this->assertSame('ZnoteX', $totpQuery['issuer'] ?? null); | |
| 61 | + | $this->assertSame('SHA1', $totpQuery['algorithm'] ?? null); | |
| 62 | + | $this->assertSame('6', $totpQuery['digits'] ?? null); | |
| 63 | + | $this->assertSame('30', $totpQuery['period'] ?? null); | |
| 64 | + | } | |
| 65 | + | } |
| @@ -0,0 +1,98 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | ||
| 9 | + | require_once dirname(__DIR__, 2) . '/engine/function/rfc6238.php'; | |
| 10 | + | require_once dirname(__DIR__, 2) . '/engine/function/twofa2.php'; | |
| 11 | + | ||
| 12 | + | final class TwoFactorV2Test extends TestCase | |
| 13 | + | { | |
| 14 | + | protected function setUp(): void | |
| 15 | + | { | |
| 16 | + | $GLOBALS['config'] = [ | |
| 17 | + | 'session_prefix' => 'znote_', | |
| 18 | + | 'twoFactorV2' => [], | |
| 19 | + | ]; | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | public function testConfigFallsBackToSafeDefaultsWhenNothingIsSet(): void | |
| 23 | + | { | |
| 24 | + | $cfg = \znote2fa_v2_config(); | |
| 25 | + | ||
| 26 | + | $this->assertFalse($cfg['enabled']); | |
| 27 | + | $this->assertTrue($cfg['email_otp_enabled']); | |
| 28 | + | $this->assertFalse($cfg['force_admins']); | |
| 29 | + | $this->assertSame(10, $cfg['recovery_codes_count']); | |
| 30 | + | $this->assertSame(30, $cfg['trusted_device_days']); | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | public function testConfigHonoursExplicitValues(): void | |
| 34 | + | { | |
| 35 | + | $GLOBALS['config']['twoFactorV2'] = [ | |
| 36 | + | 'enabled' => true, | |
| 37 | + | 'email_otp_enabled' => false, | |
| 38 | + | 'force_admins' => true, | |
| 39 | + | 'recovery_codes_count' => 0, | |
| 40 | + | 'trusted_device_days' => -5, | |
| 41 | + | ]; | |
| 42 | + | ||
| 43 | + | $cfg = \znote2fa_v2_config(); | |
| 44 | + | ||
| 45 | + | $this->assertTrue($cfg['enabled']); | |
| 46 | + | $this->assertFalse($cfg['email_otp_enabled']); | |
| 47 | + | $this->assertTrue($cfg['force_admins']); | |
| 48 | + | $this->assertSame(1, $cfg['recovery_codes_count']); | |
| 49 | + | $this->assertSame(0, $cfg['trusted_device_days']); | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | public function testEnabledMirrorsTheConfig(): void | |
| 53 | + | { | |
| 54 | + | $this->assertFalse(\znote2fa_v2_enabled()); | |
| 55 | + | $GLOBALS['config']['twoFactorV2']['enabled'] = true; | |
| 56 | + | $this->assertTrue(\znote2fa_v2_enabled()); | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | public function testAdminEnforcementDoesNotCreateAnImpossibleLoginChallenge(): void | |
| 60 | + | { | |
| 61 | + | $this->assertFalse(\znote2fa_login_challenge_required(true, ['any_enabled' => false])); | |
| 62 | + | $this->assertTrue(\znote2fa_login_challenge_required(true, ['any_enabled' => true])); | |
| 63 | + | $this->assertFalse(\znote2fa_login_challenge_required(false, ['any_enabled' => true])); | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | public function testTrustedCookieNameFollowsTheSessionPrefix(): void | |
| 67 | + | { | |
| 68 | + | $this->assertSame('znote_2fa_trust', \znote2fa_trusted_cookie_name()); | |
| 69 | + | ||
| 70 | + | $GLOBALS['config']['session_prefix'] = 'myserver_'; | |
| 71 | + | $this->assertSame('myserver_2fa_trust', \znote2fa_trusted_cookie_name()); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | public function testRecoveryFormatUppercasesAndStripsSeparators(): void | |
| 75 | + | { | |
| 76 | + | $this->assertSame('ABCDE12345', \znote2fa_recovery_format('abcde-12345')); | |
| 77 | + | $this->assertSame('ABCDE12345', \znote2fa_recovery_format(' abcde 12345 ')); | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | public function testRecoveryFormatRejectsPunctuationOnly(): void | |
| 81 | + | { | |
| 82 | + | $this->assertSame('', \znote2fa_recovery_format('---')); | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | public function testRecoveryDecodeToleratesMissingOrMalformedStorage(): void | |
| 86 | + | { | |
| 87 | + | $this->assertSame([], \znote2fa_recovery_decode(null)); | |
| 88 | + | $this->assertSame([], \znote2fa_recovery_decode('')); | |
| 89 | + | $this->assertSame([], \znote2fa_recovery_decode('not json')); | |
| 90 | + | $this->assertSame(['a', 'b'], \znote2fa_recovery_decode('["a","b"]')); | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | public function testVerifyLoginInputRejectsEmptyInput(): void | |
| 94 | + | { | |
| 95 | + | $this->assertFalse(\znote2fa_verify_login_input(1, '')); | |
| 96 | + | $this->assertFalse(\znote2fa_verify_login_input(1, ' ')); | |
| 97 | + | } | |
| 98 | + | } |
| @@ -0,0 +1,265 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | declare(strict_types=1); | |
| 4 | + | ||
| 5 | + | namespace ZnoteX\Tests\Security; | |
| 6 | + | ||
| 7 | + | use PHPUnit\Framework\TestCase; | |
| 8 | + | use ZipArchive; | |
| 9 | + | ||
| 10 | + | final class UpdateInstallSecurityTest extends TestCase | |
| 11 | + | { | |
| 12 | + | private static string $realPublicKeyFile; | |
| 13 | + | private static string $realPublicKeyBackup; | |
| 14 | + | ||
| 15 | + | public static function setUpBeforeClass(): void | |
| 16 | + | { | |
| 17 | + | // znote_update_verify_manifest() always reads the real production | |
| 18 | + | // public key from disk. To exercise that real function (rather than | |
| 19 | + | // re-implement its logic in the test) we swap that file for our | |
| 20 | + | // disposable test key for the duration of this class, and restore | |
| 21 | + | // the real one in tearDownAfterClass() even if a test fails. | |
| 22 | + | self::$realPublicKeyFile = dirname(__DIR__, 2) . '/engine/update-public.pem'; | |
| 23 | + | self::$realPublicKeyBackup = file_get_contents(self::$realPublicKeyFile); | |
| 24 | + | file_put_contents( | |
| 25 | + | self::$realPublicKeyFile, | |
| 26 | + | file_get_contents(__DIR__ . '/../fixtures/test-update-public.pem') | |
| 27 | + | ); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | public static function tearDownAfterClass(): void | |
| 31 | + | { | |
| 32 | + | file_put_contents(self::$realPublicKeyFile, self::$realPublicKeyBackup); | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | /** @dataProvider traversalPathProvider */ | |
| 36 | + | public function testPathTraversalIsRejected(string $malicious): void | |
| 37 | + | { | |
| 38 | + | $this->assertSame('', \znote_update_path($malicious)); | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | public static function traversalPathProvider(): array | |
| 42 | + | { | |
| 43 | + | return [ | |
| 44 | + | 'parent directory' => ['../../../etc/passwd'], | |
| 45 | + | 'nested traversal' => ['engine/../../config.local.php'], | |
| 46 | + | 'bare dot dot' => ['..'], | |
| 47 | + | 'dot segment' => ['engine/./config.php'], | |
| 48 | + | 'null byte' => ["engine/config\0.php"], | |
| 49 | + | 'colon (windows drive / ADS)' => ['C:/Windows/System32'], | |
| 50 | + | 'wildcard' => ['engine/*.php'], | |
| 51 | + | 'empty segment' => ['engine//config.php'], | |
| 52 | + | ]; | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | public function testAnOrdinaryPackagedPathIsAccepted(): void | |
| 56 | + | { | |
| 57 | + | $this->assertSame('engine/function/general.php', \znote_update_path('engine/function/general.php')); | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | public function testBackslashesAreNormalisedToForwardSlashes(): void | |
| 61 | + | { | |
| 62 | + | $this->assertSame('engine/function/general.php', \znote_update_path('engine\\function\\general.php')); | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | /** @dataProvider protectedPathProvider */ | |
| 66 | + | public function testProtectedPathsCanNeverBeOverwrittenByAnUpdate(string $path): void | |
| 67 | + | { | |
| 68 | + | $this->assertTrue(\znote_update_protected($path)); | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | public static function protectedPathProvider(): array | |
| 72 | + | { | |
| 73 | + | return [ | |
| 74 | + | 'config.php' => ['config.php'], | |
| 75 | + | 'config.local.php' => ['config.local.php'], | |
| 76 | + | 'a plugin file' => ['plugins/shop_coupons/plugin.php'], | |
| 77 | + | 'a theme file' => ['layouts/bloodfang/shells/default.php'], | |
| 78 | + | 'the update storage itself' => ['engine/update/installed.json'], | |
| 79 | + | 'uploaded theme images' => ['engine/img/theme/banner.png'], | |
| 80 | + | 'the installer' => ['install/index.php'], | |
| 81 | + | 'a release artifact' => ['release/2.0.3/znotex-core-2.0.3.zip'], | |
| 82 | + | 'git internals' => ['.git/config'], | |
| 83 | + | 'github workflows' => ['.github/workflows/ci.yml'], | |
| 84 | + | ]; | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | public function testAnOrdinaryCoreFileIsNotProtected(): void | |
| 88 | + | { | |
| 89 | + | $this->assertFalse(\znote_update_protected('engine/function/general.php')); | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | public function testManifestSignatureVerification(): void | |
| 93 | + | { | |
| 94 | + | [$manifest, $json, $signature] = $this->signedManifest(); | |
| 95 | + | ||
| 96 | + | $result = \znote_update_verify_manifest($json, $signature); | |
| 97 | + | $this->assertTrue($result['ok']); | |
| 98 | + | $this->assertSame($manifest['version'], $result['manifest']['version']); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | public function testTamperedManifestBodyFailsVerificationEvenWithAValidSignature(): void | |
| 102 | + | { | |
| 103 | + | [, $json, $signature] = $this->signedManifest(); | |
| 104 | + | ||
| 105 | + | $tampered = $json . ' '; | |
| 106 | + | $result = \znote_update_verify_manifest($tampered, $signature); | |
| 107 | + | $this->assertFalse($result['ok']); | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | public function testForgedSignatureIsRejected(): void | |
| 111 | + | { | |
| 112 | + | [, $json] = $this->signedManifest(); | |
| 113 | + | ||
| 114 | + | $result = \znote_update_verify_manifest($json, base64_encode(str_repeat('x', 256))); | |
| 115 | + | $this->assertFalse($result['ok']); | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | public function testManifestWithAnInvalidPackageNameIsRejected(): void | |
| 119 | + | { | |
| 120 | + | [$manifest] = $this->signedManifest(); | |
| 121 | + | $manifest['package']['name'] = '../evil.zip'; | |
| 122 | + | [$json, $signature] = $this->sign($manifest); | |
| 123 | + | ||
| 124 | + | $result = \znote_update_verify_manifest($json, $signature); | |
| 125 | + | $this->assertFalse($result['ok']); | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | public function testManifestListingAProtectedFileIsRejected(): void | |
| 129 | + | { | |
| 130 | + | // The signature check itself does not know about protected paths - | |
| 131 | + | // that is enforced separately by znote_update_extract(). This test | |
| 132 | + | // documents that boundary: a manifest can list any path shape as far | |
| 133 | + | // as signature verification is concerned, but znote_update_path() | |
| 134 | + | // still normalises it, so a raw ".." entry is caught here already. | |
| 135 | + | [$manifest] = $this->signedManifest(); | |
| 136 | + | $manifest['files'] = ['../../config.local.php' => str_repeat('a', 64)]; | |
| 137 | + | [$json, $signature] = $this->sign($manifest); | |
| 138 | + | ||
| 139 | + | $result = \znote_update_verify_manifest($json, $signature); | |
| 140 | + | $this->assertFalse($result['ok']); | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | public function testExtractRejectsAFileNotListedInTheSignedManifest(): void | |
| 144 | + | { | |
| 145 | + | $zipFile = $this->buildZip(['engine/function/general.php' => 'safe contents']); | |
| 146 | + | $destination = sys_get_temp_dir() . '/znotex-test-extract-' . bin2hex(random_bytes(6)); | |
| 147 | + | ||
| 148 | + | try { | |
| 149 | + | $files = []; // Nothing is listed as signed. | |
| 150 | + | $result = \znote_update_extract($zipFile, $files, $destination); | |
| 151 | + | $this->assertFalse($result['ok']); | |
| 152 | + | $this->assertStringContainsString('unexpected or protected', $result['error']); | |
| 153 | + | } finally { | |
| 154 | + | @unlink($zipFile); | |
| 155 | + | $this->removeTree($destination); | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | ||
| 159 | + | public function testExtractRejectsAProtectedFileEvenIfItsHashMatches(): void | |
| 160 | + | { | |
| 161 | + | $content = "<?php \$config['sqlPassword'] = 'stolen';"; | |
| 162 | + | $hash = hash('sha256', $content); | |
| 163 | + | $zipFile = $this->buildZip(['config.local.php' => $content]); | |
| 164 | + | $destination = sys_get_temp_dir() . '/znotex-test-extract-' . bin2hex(random_bytes(6)); | |
| 165 | + | ||
| 166 | + | try { | |
| 167 | + | $result = \znote_update_extract($zipFile, ['config.local.php' => $hash], $destination); | |
| 168 | + | $this->assertFalse($result['ok']); | |
| 169 | + | $this->assertStringContainsString('unexpected or protected', $result['error']); | |
| 170 | + | } finally { | |
| 171 | + | @unlink($zipFile); | |
| 172 | + | $this->removeTree($destination); | |
| 173 | + | } | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | public function testExtractRejectsAFileWhoseContentDoesNotMatchItsSignedHash(): void | |
| 177 | + | { | |
| 178 | + | $zipFile = $this->buildZip(['engine/function/general.php' => 'tampered contents']); | |
| 179 | + | $destination = sys_get_temp_dir() . '/znotex-test-extract-' . bin2hex(random_bytes(6)); | |
| 180 | + | ||
| 181 | + | try { | |
| 182 | + | $wrongHash = hash('sha256', 'original untampered contents'); | |
| 183 | + | $result = \znote_update_extract($zipFile, ['engine/function/general.php' => $wrongHash], $destination); | |
| 184 | + | $this->assertFalse($result['ok']); | |
| 185 | + | $this->assertStringContainsString('checksum validation', $result['error']); | |
| 186 | + | } finally { | |
| 187 | + | @unlink($zipFile); | |
| 188 | + | $this->removeTree($destination); | |
| 189 | + | } | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | public function testExtractAcceptsAFileThatMatchesItsSignedHash(): void | |
| 193 | + | { | |
| 194 | + | $content = 'genuine contents'; | |
| 195 | + | $hash = hash('sha256', $content); | |
| 196 | + | $zipFile = $this->buildZip(['engine/function/example.php' => $content]); | |
| 197 | + | $destination = sys_get_temp_dir() . '/znotex-test-extract-' . bin2hex(random_bytes(6)); | |
| 198 | + | ||
| 199 | + | try { | |
| 200 | + | $result = \znote_update_extract($zipFile, ['engine/function/example.php' => $hash], $destination); | |
| 201 | + | $this->assertTrue($result['ok']); | |
| 202 | + | $this->assertSame($content, file_get_contents($destination . '/engine/function/example.php')); | |
| 203 | + | } finally { | |
| 204 | + | @unlink($zipFile); | |
| 205 | + | $this->removeTree($destination); | |
| 206 | + | } | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | /** @return array{0: array, 1: string, 2: string} */ | |
| 210 | + | private function signedManifest(): array | |
| 211 | + | { | |
| 212 | + | $manifest = [ | |
| 213 | + | 'schema' => 1, | |
| 214 | + | 'version' => '9.9.9', | |
| 215 | + | 'package' => [ | |
| 216 | + | 'name' => 'znotex-core-9.9.9.zip', | |
| 217 | + | 'sha256' => str_repeat('a', 64), | |
| 218 | + | 'size' => 123, | |
| 219 | + | ], | |
| 220 | + | 'files' => [ | |
| 221 | + | 'engine/function/general.php' => str_repeat('b', 64), | |
| 222 | + | ], | |
| 223 | + | ]; | |
| 224 | + | [$json, $signature] = $this->sign($manifest); | |
| 225 | + | ||
| 226 | + | return [$manifest, $json, $signature]; | |
| 227 | + | } | |
| 228 | + | ||
| 229 | + | private function sign(array $manifest): array | |
| 230 | + | { | |
| 231 | + | $json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; | |
| 232 | + | $privateKey = file_get_contents(__DIR__ . '/../fixtures/test-update-private.pem'); | |
| 233 | + | openssl_sign($json, $signature, $privateKey, OPENSSL_ALGO_SHA256); | |
| 234 | + | ||
| 235 | + | return [$json, base64_encode($signature) . "\n"]; | |
| 236 | + | } | |
| 237 | + | ||
| 238 | + | private function buildZip(array $files): string | |
| 239 | + | { | |
| 240 | + | $path = sys_get_temp_dir() . '/znotex-test-' . bin2hex(random_bytes(6)) . '.zip'; | |
| 241 | + | $zip = new ZipArchive(); | |
| 242 | + | $zip->open($path, ZipArchive::CREATE); | |
| 243 | + | foreach ($files as $name => $content) { | |
| 244 | + | $zip->addFromString($name, $content); | |
| 245 | + | } | |
| 246 | + | $zip->close(); | |
| 247 | + | ||
| 248 | + | return $path; | |
| 249 | + | } | |
| 250 | + | ||
| 251 | + | private function removeTree(string $path): void | |
| 252 | + | { | |
| 253 | + | if (!is_dir($path)) { | |
| 254 | + | return; | |
| 255 | + | } | |
| 256 | + | $iterator = new \RecursiveIteratorIterator( | |
| 257 | + | new \RecursiveDirectoryIterator($path, \FilesystemIterator::SKIP_DOTS), | |
| 258 | + | \RecursiveIteratorIterator::CHILD_FIRST | |
| 259 | + | ); | |
| 260 | + | foreach ($iterator as $item) { | |
| 261 | + | $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); | |
| 262 | + | } | |
| 263 | + | rmdir($path); | |
| 264 | + | } | |
| 265 | + | } |
| @@ -0,0 +1,149 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | if (PHP_SAPI !== 'cli') { | |
| 4 | + | http_response_code(403); | |
| 5 | + | exit(1); | |
| 6 | + | } | |
| 7 | + | ||
| 8 | + | $root = dirname(__DIR__); | |
| 9 | + | $metadataFile = __DIR__ . '/release-metadata.json'; | |
| 10 | + | $metadata = json_decode((string)file_get_contents($metadataFile), true); | |
| 11 | + | $version = (string)($argv[1] ?? ($metadata['version'] ?? '')); | |
| 12 | + | $outputRoot = rtrim((string)($argv[2] ?? ($root . '/release')), '/\\'); | |
| 13 | + | $current = (string)require $root . '/engine/version.php'; | |
| 14 | + | ||
| 15 | + | if (!is_array($metadata) || $version === '' || $version !== (string)($metadata['version'] ?? '') || $version !== $current) { | |
| 16 | + | fwrite(STDERR, "The version must match tools/release-metadata.json and engine/version.php.\n"); | |
| 17 | + | exit(1); | |
| 18 | + | } | |
| 19 | + | if (!class_exists('ZipArchive') || !function_exists('openssl_sign')) { | |
| 20 | + | fwrite(STDERR, "PHP Zip and OpenSSL are required.\n"); | |
| 21 | + | exit(1); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | $signingDirectory = $outputRoot . '/.signing'; | |
| 25 | + | $privateKeyFile = $signingDirectory . '/private.pem'; | |
| 26 | + | $publicKeyFile = $root . '/engine/update-public.pem'; | |
| 27 | + | if (!is_dir($signingDirectory) && !mkdir($signingDirectory, 0700, true) && !is_dir($signingDirectory)) { | |
| 28 | + | fwrite(STDERR, "The signing directory cannot be created.\n"); | |
| 29 | + | exit(1); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | if (!is_file($privateKeyFile)) { | |
| 33 | + | fwrite(STDERR, "The private signing key is missing. Run tools/generate-release-key.ps1 once.\n"); | |
| 34 | + | exit(1); | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | $privatePem = (string)file_get_contents($privateKeyFile); | |
| 38 | + | $key = openssl_pkey_get_private($privatePem); | |
| 39 | + | $details = $key !== false ? openssl_pkey_get_details($key) : false; | |
| 40 | + | if ($key === false || !is_array($details) || !is_file($publicKeyFile) || trim((string)file_get_contents($publicKeyFile)) !== trim((string)$details['key'])) { | |
| 41 | + | fwrite(STDERR, "The private key does not match engine/update-public.pem.\n"); | |
| 42 | + | exit(1); | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | $releaseDirectory = $outputRoot . '/' . $version; | |
| 46 | + | if (!is_dir($releaseDirectory) && !mkdir($releaseDirectory, 0755, true) && !is_dir($releaseDirectory)) { | |
| 47 | + | fwrite(STDERR, "The release directory cannot be created.\n"); | |
| 48 | + | exit(1); | |
| 49 | + | } | |
| 50 | + | if (is_file(__DIR__ . '/RELEASE.md')) { | |
| 51 | + | copy(__DIR__ . '/RELEASE.md', $outputRoot . '/README.md'); | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | $allowedDirectories = array('admin', 'api', 'assets', 'engine', 'locale', 'SQL', 'vendor'); | |
| 55 | + | $excludedPrefixes = array('engine/cache/', 'engine/img/theme/', 'engine/update/'); | |
| 56 | + | $files = array(); | |
| 57 | + | ||
| 58 | + | $accept = static function (string $relative) use ($excludedPrefixes): bool { | |
| 59 | + | $relative = str_replace('\\', '/', $relative); | |
| 60 | + | foreach ($excludedPrefixes as $prefix) { | |
| 61 | + | if (str_starts_with($relative, $prefix)) { | |
| 62 | + | return false; | |
| 63 | + | } | |
| 64 | + | } | |
| 65 | + | return !str_ends_with($relative, '.znote-update'); | |
| 66 | + | }; | |
| 67 | + | ||
| 68 | + | foreach ($allowedDirectories as $directory) { | |
| 69 | + | $base = $root . '/' . $directory; | |
| 70 | + | if (!is_dir($base)) { | |
| 71 | + | continue; | |
| 72 | + | } | |
| 73 | + | $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS)); | |
| 74 | + | foreach ($iterator as $item) { | |
| 75 | + | if (!$item->isFile() || $item->isLink()) { | |
| 76 | + | continue; | |
| 77 | + | } | |
| 78 | + | $relative = str_replace('\\', '/', substr($item->getPathname(), strlen($root) + 1)); | |
| 79 | + | if ($accept($relative)) { | |
| 80 | + | $files[$relative] = $item->getPathname(); | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | foreach (glob($root . '/*.php') ?: array() as $file) { | |
| 86 | + | $name = basename($file); | |
| 87 | + | if (!in_array(strtolower($name), array('config.php', 'config.local.php'), true)) { | |
| 88 | + | $files[$name] = $file; | |
| 89 | + | } | |
| 90 | + | } | |
| 91 | + | foreach (array('.htaccess', 'composer.json', 'composer.lock', 'LICENSE', 'README.md', 'config.countries.php') as $name) { | |
| 92 | + | if (is_file($root . '/' . $name)) { | |
| 93 | + | $files[$name] = $root . '/' . $name; | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ksort($files); | |
| 97 | + | ||
| 98 | + | $packageName = 'znotex-core-' . $version . '.zip'; | |
| 99 | + | $packageFile = $releaseDirectory . '/' . $packageName; | |
| 100 | + | $zip = new ZipArchive(); | |
| 101 | + | if ($zip->open($packageFile, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { | |
| 102 | + | fwrite(STDERR, "The release ZIP cannot be created.\n"); | |
| 103 | + | exit(1); | |
| 104 | + | } | |
| 105 | + | $hashes = array(); | |
| 106 | + | foreach ($files as $relative => $file) { | |
| 107 | + | if (!$zip->addFile($file, $relative)) { | |
| 108 | + | $zip->close(); | |
| 109 | + | fwrite(STDERR, "A file cannot be added: " . $relative . "\n"); | |
| 110 | + | exit(1); | |
| 111 | + | } | |
| 112 | + | $hashes[$relative] = hash_file('sha256', $file); | |
| 113 | + | } | |
| 114 | + | $zip->close(); | |
| 115 | + | ||
| 116 | + | $manifest = $metadata; | |
| 117 | + | $manifest['schema'] = 1; | |
| 118 | + | $manifest['project'] = 'ZnoteX'; | |
| 119 | + | $manifest['version'] = $version; | |
| 120 | + | $manifest['published_at'] = gmdate(DATE_ATOM); | |
| 121 | + | $manifest['package'] = array( | |
| 122 | + | 'name' => $packageName, | |
| 123 | + | 'sha256' => hash_file('sha256', $packageFile), | |
| 124 | + | 'size' => filesize($packageFile), | |
| 125 | + | ); | |
| 126 | + | $manifest['installation'] = array( | |
| 127 | + | 'mode' => 'signed-core-update', | |
| 128 | + | 'backup' => true, | |
| 129 | + | 'rollback' => 'files', | |
| 130 | + | 'database_policy' => 'expand-only' | |
| 131 | + | ); | |
| 132 | + | $manifest['protected_paths'] = array('config.php', 'config.local.php', 'plugins/', 'layouts/', 'engine/cache/', 'engine/img/theme/', 'engine/update/', 'install/', 'release/'); | |
| 133 | + | $manifest['links'] = array( | |
| 134 | + | 'repository' => 'https://github.com/Open-Games-Community/ZnoteX', | |
| 135 | + | 'release' => 'https://github.com/Open-Games-Community/ZnoteX/releases/tag/v' . $version | |
| 136 | + | ); | |
| 137 | + | $manifest['files'] = $hashes; | |
| 138 | + | ||
| 139 | + | $json = json_encode($manifest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . "\n"; | |
| 140 | + | if (!openssl_sign($json, $signature, $key, OPENSSL_ALGO_SHA256)) { | |
| 141 | + | fwrite(STDERR, "The release manifest cannot be signed.\n"); | |
| 142 | + | exit(1); | |
| 143 | + | } | |
| 144 | + | file_put_contents($releaseDirectory . '/update.json', $json, LOCK_EX); | |
| 145 | + | file_put_contents($releaseDirectory . '/update.json.sig', base64_encode($signature) . "\n", LOCK_EX); | |
| 146 | + | ||
| 147 | + | echo $releaseDirectory . PHP_EOL; | |
| 148 | + | echo $packageName . PHP_EOL; | |
| 149 | + | echo count($hashes) . " files\n"; |
| @@ -0,0 +1,33 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | if (PHP_SAPI !== 'cli') { | |
| 4 | + | exit(1); | |
| 5 | + | } | |
| 6 | + | ||
| 7 | + | $outputRoot = rtrim((string)($argv[1] ?? ''), '/\\'); | |
| 8 | + | $publicKeyFile = (string)($argv[2] ?? ''); | |
| 9 | + | $privateKeyFile = $outputRoot . '/.signing/private.pem'; | |
| 10 | + | $options = array( | |
| 11 | + | 'config' => __DIR__ . '/openssl.cnf', | |
| 12 | + | 'private_key_bits' => 3072, | |
| 13 | + | 'private_key_type' => OPENSSL_KEYTYPE_RSA, | |
| 14 | + | ); | |
| 15 | + | ||
| 16 | + | if ($outputRoot === '' || $publicKeyFile === '' || (is_file($privateKeyFile) && filesize($privateKeyFile) > 0)) { | |
| 17 | + | fwrite(STDERR, "Invalid key destination or an existing private key.\n"); | |
| 18 | + | exit(1); | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | $key = openssl_pkey_new($options); | |
| 22 | + | if ($key === false || !openssl_pkey_export($key, $privatePem, null, $options)) { | |
| 23 | + | fwrite(STDERR, "OpenSSL could not generate the private key.\n"); | |
| 24 | + | exit(1); | |
| 25 | + | } | |
| 26 | + | $details = openssl_pkey_get_details($key); | |
| 27 | + | if (!is_array($details) || file_put_contents($privateKeyFile, $privatePem, LOCK_EX) === false || file_put_contents($publicKeyFile, $details['key'], LOCK_EX) === false) { | |
| 28 | + | fwrite(STDERR, "The signing keys could not be saved.\n"); | |
| 29 | + | exit(1); | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | echo $privateKeyFile . PHP_EOL; | |
| 33 | + | echo $publicKeyFile . PHP_EOL; |
| @@ -0,0 +1,27 @@ | |||
| 1 | + | param( | |
| 2 | + | [Parameter(Mandatory = $true)][string]$OutputRoot, | |
| 3 | + | [Parameter(Mandatory = $true)][string]$PublicKeyFile, | |
| 4 | + | [string]$PhpBinary = 'php' | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | $signingDirectory = Join-Path $OutputRoot '.signing' | |
| 8 | + | $privateKeyFile = Join-Path $signingDirectory 'private.pem' | |
| 9 | + | if ((Test-Path -LiteralPath $privateKeyFile) -and (Get-Item -LiteralPath $privateKeyFile).Length -gt 0) { | |
| 10 | + | throw 'The private signing key already exists.' | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | New-Item -ItemType Directory -Path $signingDirectory -Force | Out-Null | |
| 14 | + | $randomFile = Join-Path $signingDirectory '.rnd' | |
| 15 | + | $rng = [System.Security.Cryptography.RandomNumberGenerator]::Create() | |
| 16 | + | $bytes = New-Object byte[] 256 | |
| 17 | + | $rng.GetBytes($bytes) | |
| 18 | + | [System.IO.File]::WriteAllBytes($randomFile, $bytes) | |
| 19 | + | $rng.Dispose() | |
| 20 | + | ||
| 21 | + | $env:OPENSSL_CONF = Join-Path $PSScriptRoot 'openssl.cnf' | |
| 22 | + | $env:RANDFILE = $randomFile | |
| 23 | + | & $PhpBinary (Join-Path $PSScriptRoot 'generate-release-key.php') $OutputRoot $PublicKeyFile | |
| 24 | + | if ($LASTEXITCODE -ne 0) { | |
| 25 | + | throw 'The release signing key could not be generated.' | |
| 26 | + | } | |
| 27 | + | Remove-Item -LiteralPath $randomFile -Force |
| @@ -0,0 +1,10 @@ | |||
| 1 | + | openssl_conf = openssl_init | |
| 2 | + | ||
| 3 | + | [openssl_init] | |
| 4 | + | providers = provider_sect | |
| 5 | + | ||
| 6 | + | [provider_sect] | |
| 7 | + | default = default_sect | |
| 8 | + | ||
| 9 | + | [default_sect] | |
| 10 | + | activate = 1 |
| @@ -0,0 +1,29 @@ | |||
| 1 | + | { | |
| 2 | + | "version": "2.0.5", | |
| 3 | + | "channel": "stable", | |
| 4 | + | "title": "ZnoteX 2.0.5", | |
| 5 | + | "summary": "BBCode paste fix, a plugin-page CSS crash fix, an overhauled default theme, and a more useful admin dashboard/analytics/plugins experience.", | |
| 6 | + | "description": "This release fixes a News/BBCode bug where pasted images lost their size formatting, and a fatal error that made some installed plugins' admin pages render completely unstyled (white, no CSS). It also modernizes the default theme end to end - account pages, character management, guilds, downloads, highscores, market, support/helpdesk and more all move to a consistent, theme-adaptive card/table/button design with no hardcoded colors, plus a new dedicated character-management page (replacing the old two-dropdown editor) and a new Houses page for the default theme. On the admin side, the Dashboard gains two new activity boxes, Analytics' \"Top countries\" no longer misaligns rows depending on which flag icons happen to exist, plugin updates now report the real database error instead of a message cut off at 60 characters (and no longer fail on a stray file BOM), and long admin error messages wrap instead of being clipped.", | |
| 7 | + | "highlights": [ | |
| 8 | + | "Fixed: pasting an image into a News post via the BBCode editor lost its explicit width/height, which could resize or break its layout - image dimensions are now preserved on paste.", | |
| 9 | + | "Fixed: some installed plugins' admin pages rendered as a blank white page with no CSS, caused by a missing acp_color_field() helper the plugin settings framework depended on.", | |
| 10 | + | "Fixed: plugin install/update could fail with a confusing, truncated error (\"...first: CREATE TABLE...\") when the plugin's install.sql file started with a UTF-8 BOM; the BOM is now stripped automatically and the full failing statement plus the real database error is shown instead of a 60-character snippet.", | |
| 11 | + | "Fixed: long admin panel error/flash messages (e.g. a failed plugin update) were visually clipped instead of wrapping onto multiple lines.", | |
| 12 | + | "Fixed: the Analytics \"Top countries\" list could look misaligned because it reused the site-language flag icons (only 5 exist: de/es/pl/pt/uk); every country now gets a same-width flag emoji instead, so rows always line up regardless of country.", | |
| 13 | + | "Default theme (motor) overhaul: My Account, Character List, Settings, Change Password, Create Character, Downloads, Who's Online, Highscores, Guilds/Top Guilds/Latest Deaths/Kill Statistics/Bans, Server Information, Creatures, Monster Loot, Spells, Item Market, Support, Contact, Vote for Us, Credits and the Helpdesk pages all move to a consistent card/title-banner/table styling using the theme's own CSS variables - no hardcoded colors, so the look follows whatever the theme defines.", | |
| 14 | + | "New: a dedicated Character Management page replaces the old select-a-character/select-an-action dropdown combo on My Account - each character gets a per-row Edit button leading to a page with Change Name, Change Gender, Visibility, Comment and Delete Character as clearly separated actions, including remaining-ticket counts for name/gender changes.", | |
| 15 | + | "New: a proper Houses page for the default theme (town/order/sort filters plus a styled results table) where previously it fell back to unstyled generic markup.", | |
| 16 | + | "The account Guild page (guild list, Create Guild, and the full guild management/overview screen) now uses the same styled cards, tables and forms as the rest of the modernized theme.", | |
| 17 | + | "The Downloads page dropped the outdated \"you need an IP changer\" instructions and reworked the client/tool cards so each one shows a compact Download button instead of one long clickable sentence.", | |
| 18 | + | "New: two Dashboard boxes - \"Recent Shop Purchases\" (from the completed shop-purchase log) and \"Recent Points Purchases\" (from real-money top-ups) - each showing the account, what was bought/paid, and when.", | |
| 19 | + | "The Contact page's placeholder text (shown until an admin sets contact_info) no longer says \"TODO: Edit the contact details here.\" - it now points the admin at the Admin Panel, in all 5 shipped languages." | |
| 20 | + | ], | |
| 21 | + | "warnings": [ | |
| 22 | + | "Back up the website and database independently before every production update." | |
| 23 | + | ], | |
| 24 | + | "requirements": { | |
| 25 | + | "php": ">=8.1", | |
| 26 | + | "extensions": ["curl", "json", "openssl", "zip"] | |
| 27 | + | }, | |
| 28 | + | "migrations": [] | |
| 29 | + | } |
| @@ -0,0 +1,56 @@ | |||
| 1 | + | <?php | |
| 2 | + | ||
| 3 | + | if (PHP_SAPI !== 'cli') { | |
| 4 | + | exit(1); | |
| 5 | + | } | |
| 6 | + | ||
| 7 | + | $root = dirname(__DIR__); | |
| 8 | + | $directory = rtrim((string)($argv[1] ?? ''), '/\\'); | |
| 9 | + | $jsonFile = $directory . '/update.json'; | |
| 10 | + | $signatureFile = $directory . '/update.json.sig'; | |
| 11 | + | if (!is_file($jsonFile) || !is_file($signatureFile) || !is_file($root . '/engine/update-public.pem')) { | |
| 12 | + | fwrite(STDERR, "Release files are missing.\n"); | |
| 13 | + | exit(1); | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | $json = (string)file_get_contents($jsonFile); | |
| 17 | + | $signature = base64_decode(trim((string)file_get_contents($signatureFile)), true); | |
| 18 | + | $manifest = json_decode($json, true); | |
| 19 | + | if ($signature === false || !is_array($manifest) || openssl_verify($json, $signature, (string)file_get_contents($root . '/engine/update-public.pem'), OPENSSL_ALGO_SHA256) !== 1) { | |
| 20 | + | fwrite(STDERR, "The release signature is invalid.\n"); | |
| 21 | + | exit(1); | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | $package = $directory . '/' . (string)$manifest['package']['name']; | |
| 25 | + | if (!is_file($package) || hash_file('sha256', $package) !== (string)$manifest['package']['sha256'] || filesize($package) !== (int)$manifest['package']['size']) { | |
| 26 | + | fwrite(STDERR, "The package checksum or size is invalid.\n"); | |
| 27 | + | exit(1); | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | $zip = new ZipArchive(); | |
| 31 | + | if ($zip->open($package) !== true) { | |
| 32 | + | fwrite(STDERR, "The package cannot be opened.\n"); | |
| 33 | + | exit(1); | |
| 34 | + | } | |
| 35 | + | $seen = array(); | |
| 36 | + | for ($index = 0; $index < $zip->numFiles; $index++) { | |
| 37 | + | $path = (string)$zip->getNameIndex($index); | |
| 38 | + | if (str_ends_with($path, '/')) { | |
| 39 | + | continue; | |
| 40 | + | } | |
| 41 | + | $data = $zip->getFromIndex($index); | |
| 42 | + | if (!is_string($data) || !isset($manifest['files'][$path]) || hash('sha256', $data) !== (string)$manifest['files'][$path]) { | |
| 43 | + | $zip->close(); | |
| 44 | + | fwrite(STDERR, "A packaged file is invalid: " . $path . "\n"); | |
| 45 | + | exit(1); | |
| 46 | + | } | |
| 47 | + | $seen[$path] = true; | |
| 48 | + | } | |
| 49 | + | $zip->close(); | |
| 50 | + | if (count($seen) !== count($manifest['files'])) { | |
| 51 | + | fwrite(STDERR, "The signed file list does not match the package.\n"); | |
| 52 | + | exit(1); | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | echo "Valid release " . $manifest['version'] . "\n"; | |
| 56 | + | echo count($seen) . " signed files\n"; |
| @@ -0,0 +1,142 @@ | |||
| 1 | + | <?php require_once 'engine/init.php'; theme_open(); | |
| 2 | + | ||
| 3 | + | // Cache the results | |
| 4 | + | $cache = new Cache('engine/cache/topGuilds'); | |
| 5 | + | if ($cache->hasExpired()) { | |
| 6 | + | $guilds = db()->fetchAll(" | |
| 7 | + | SELECT `g`.`id` AS `id`, `g`.`name` AS `name`, COALESCE(`kills`.`frags`, 0) AS `frags` | |
| 8 | + | FROM `guilds` g | |
| 9 | + | LEFT JOIN ( | |
| 10 | + | SELECT `gm`.`guild_id` AS `guild_id`, COUNT(*) AS `frags` | |
| 11 | + | FROM `player_deaths` pd | |
| 12 | + | INNER JOIN `players` p ON `p`.`name` = `pd`.`killed_by` | |
| 13 | + | INNER JOIN `guild_membership` gm ON `gm`.`player_id` = `p`.`id` | |
| 14 | + | WHERE `pd`.`unjustified` = 1 | |
| 15 | + | GROUP BY `gm`.`guild_id` | |
| 16 | + | ) kills ON `kills`.`guild_id` = `g`.`id` | |
| 17 | + | ORDER BY `frags` DESC, `g`.`name` ASC | |
| 18 | + | LIMIT 0, 10; | |
| 19 | + | "); | |
| 20 | + | ||
| 21 | + | $cache->setContent($guilds); | |
| 22 | + | $cache->save(); | |
| 23 | + | } else { | |
| 24 | + | $guilds = $cache->load(); | |
| 25 | + | } | |
| 26 | + | $count = 1; | |
| 27 | + | ||
| 28 | + | function convert_number_to_words($number) { | |
| 29 | + | ||
| 30 | + | $hyphen = '-'; | |
| 31 | + | $conjunction = ' and '; | |
| 32 | + | $separator = ', '; | |
| 33 | + | $negative= 'negative '; | |
| 34 | + | $decimal = ' point '; | |
| 35 | + | $dictionary = array( | |
| 36 | + | 0 => 'zero', | |
| 37 | + | 1 => 'first', | |
| 38 | + | 2 => 'second', | |
| 39 | + | 3 => 'third', | |
| 40 | + | 4 => 'fourth', | |
| 41 | + | 5 => 'fifth', | |
| 42 | + | 6 => 'sixth', | |
| 43 | + | 7 => 'seventh', | |
| 44 | + | 8 => 'eighth', | |
| 45 | + | 9 => 'ninth', | |
| 46 | + | 10 => 'tenth', | |
| 47 | + | 11 => 'eleventh', | |
| 48 | + | 12 => 'twelve', | |
| 49 | + | 13 => 'thirteen', | |
| 50 | + | 14 => 'fourteen', | |
| 51 | + | 15 => 'fifteen', | |
| 52 | + | 16 => 'sixteen', | |
| 53 | + | 17 => 'seventeen', | |
| 54 | + | 18 => 'eighteen', | |
| 55 | + | 19 => 'nineteen', | |
| 56 | + | 20 => 'twenty', | |
| 57 | + | 30 => 'thirty', | |
| 58 | + | 40 => 'fourty', | |
| 59 | + | 50 => 'fifty', | |
| 60 | + | 60 => 'sixty', | |
| 61 | + | 70 => 'seventy', | |
| 62 | + | 80 => 'eighty', | |
| 63 | + | 90 => 'ninety', | |
| 64 | + | 100 => 'hundred', | |
| 65 | + | 1000 => 'thousand', | |
| 66 | + | 1000000 => 'million', | |
| 67 | + | 1000000000 => 'billion', | |
| 68 | + | 1000000000000 => 'trillion', | |
| 69 | + | 1000000000000000 => 'quadrillion', | |
| 70 | + | 1000000000000000000 => 'quintillion' | |
| 71 | + | ); | |
| 72 | + | ||
| 73 | + | if (!is_numeric($number)) { | |
| 74 | + | return false; | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | if (($number >= 0 && (int) $number < 0) || (int) $number < 0 - PHP_INT_MAX) { | |
| 78 | + | // overflow | |
| 79 | + | trigger_error( | |
| 80 | + | 'convert_number_to_words only accepts numbers between -' . PHP_INT_MAX . ' and ' . PHP_INT_MAX, | |
| 81 | + | E_USER_WARNING | |
| 82 | + | ); | |
| 83 | + | return false; | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | if ($number < 0) { | |
| 87 | + | return $negative . convert_number_to_words(abs($number)); | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | $string = $fraction = null; | |
| 91 | + | ||
| 92 | + | if (strpos($number, '.') !== false) { | |
| 93 | + | list($number, $fraction) = explode('.', $number); | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | switch (true) { | |
| 97 | + | case $number < 21: | |
| 98 | + | $string = $dictionary[$number]; | |
| 99 | + | break; | |
| 100 | + | case $number < 100: | |
| 101 | + | $tens = ((int) ($number / 10)) * 10; | |
| 102 | + | $units = $number % 10; | |
| 103 | + | $string = $dictionary[$tens]; | |
| 104 | + | if ($units) { | |
| 105 | + | $string .= $hyphen . $dictionary[$units]; | |
| 106 | + | } | |
| 107 | + | break; | |
| 108 | + | case $number < 1000: | |
| 109 | + | $hundreds = $number / 100; | |
| 110 | + | $remainder = $number % 100; | |
| 111 | + | $string = $dictionary[$hundreds] . ' ' . $dictionary[100]; | |
| 112 | + | if ($remainder) { | |
| 113 | + | $string .= $conjunction . convert_number_to_words($remainder); | |
| 114 | + | } | |
| 115 | + | break; | |
| 116 | + | default: | |
| 117 | + | $baseUnit = pow(1000, floor(log($number, 1000))); | |
| 118 | + | $numBaseUnits = (int) ($number / $baseUnit); | |
| 119 | + | $remainder = $number % $baseUnit; | |
| 120 | + | $string = convert_number_to_words($numBaseUnits) . ' ' . $dictionary[$baseUnit]; | |
| 121 | + | if ($remainder) { | |
| 122 | + | $string .= $remainder < 100 ? $conjunction : $separator; | |
| 123 | + | $string .= convert_number_to_words($remainder); | |
| 124 | + | } | |
| 125 | + | break; | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | if (null !== $fraction && is_numeric($fraction)) { | |
| 129 | + | $string .= $decimal; | |
| 130 | + | $words = array(); | |
| 131 | + | foreach (str_split((string) $fraction) as $number) { | |
| 132 | + | $words[] = $dictionary[$number]; | |
| 133 | + | } | |
| 134 | + | $string .= implode(' ', $words); | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | return $string; | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | view('topguilds'); | |
| 141 | + | ||
| 142 | + | theme_close(); |
| @@ -0,0 +1,43 @@ | |||
| 1 | + | <?php | |
| 2 | + | require_once 'engine/init.php'; | |
| 3 | + | theme_open(); | |
| 4 | + | if (!$config['toponline']['enabled']) { | |
| 5 | + | echo 'This page has been disabled at config.php.'; | |
| 6 | + | theme_close(); | |
| 7 | + | exit(); | |
| 8 | + | } | |
| 9 | + | $limit = $config['toponline']['limit']; | |
| 10 | + | $type = (isset($_GET['type'])) ? getValue($_GET['type'] ?? null) : false; | |
| 11 | + | ||
| 12 | + | function onlineTimeTotal($value) | |
| 13 | + | { | |
| 14 | + | $hours = floor($value / 3600); | |
| 15 | + | $value = $value - $hours * 3600; | |
| 16 | + | $minutes = floor($value / 60); | |
| 17 | + | return '<font color="black">'.$hours.'h '.$minutes.'m</font>'; | |
| 18 | + | } | |
| 19 | + | function hours_and_minutes($value, $color = 1) | |
| 20 | + | { | |
| 21 | + | $hours = floor($value / 3600); | |
| 22 | + | $value = $value - $hours * 3600; | |
| 23 | + | $minutes = floor($value / 60); | |
| 24 | + | if($color != 1) | |
| 25 | + | return '<font color="black">'.$hours.'h '.$minutes.'m</font>'; | |
| 26 | + | else | |
| 27 | + | if($hours >= 12) | |
| 28 | + | return '<font color="red">'.$hours.'h '.$minutes.'m</font>'; | |
| 29 | + | elseif($hours >= 6) | |
| 30 | + | return '<font color="black">'.$hours.'h '.$minutes.'m</font>'; | |
| 31 | + | else | |
| 32 | + | return '<font color="green">'.$hours.'h '.$minutes.'m</font>'; | |
| 33 | + | } | |
| 34 | + | if(empty($type)) | |
| 35 | + | $znotePlayers = db()->fetchAll('SELECT * FROM `znote_players` AS `z` JOIN `players` AS `p` WHERE `p`.`id`=`z`.`player_id` and `p`.`group_id` < 3 ORDER BY `onlinetimetoday` DESC LIMIT ?', [(int)$limit]); | |
| 36 | + | elseif($type == "sum") | |
| 37 | + | $znotePlayers = db()->fetchAll('SELECT * FROM `znote_players` AS `z` JOIN `players` AS `p` WHERE `p`.`id`=`z`.`player_id` and `p`.`group_id` < 3 ORDER BY `z`.`onlinetimeall` DESC LIMIT ?', [(int)$limit]); | |
| 38 | + | elseif($type >= 1 && $type <= 4) | |
| 39 | + | $znotePlayers = db()->fetchAll('SELECT * FROM `znote_players` AS `z` JOIN `players` AS `p` WHERE `p`.`id`=`z`.`player_id` and `p`.`group_id` < 3 ORDER BY `onlinetime' . (int) $type . '` DESC LIMIT ?', [(int)$limit]); | |
| 40 | + | ||
| 41 | + | view('toponline'); | |
| 42 | + | ||
| 43 | + | theme_close(); |