From bf6370a7b05f6a82ec585fe2f49cf31bb4571af1 Mon Sep 17 00:00:00 2001 From: s3inlc Date: Thu, 17 Sep 2026 14:35:45 +0200 Subject: [PATCH 1/6] adding attribute to cracker binary to associate them with access groups --- ci/apiv2/test_cracker.py | 2 +- ci/apiv2/test_logentry.py | 8 +- ci/apiv2/test_taskwrapper.py | 3 +- .../testfiles/cracker/create_cracker_001.json | 4 +- .../testfiles/cracker/create_cracker_002.json | 4 +- ci/phpunit/TestBase.php | 4 +- ci/phpunit/dba/AbstractModelFactoryTest.php | 2 +- ci/phpunit/dba/MassUpdateSetTest.php | 2 +- ci/phpunit/downloadapi/DownloadAppTest.php | 11 +- .../openapi/crackerbinarytype.spec.json | 28 ++- ci/phpunit/inc/UtilTest.php | 3 +- ci/phpunit/inc/utils/AccessGroupUtilsTest.php | 8 + ci/phpunit/inc/utils/AccessUtilsTest.php | 2 +- .../inc/utils/CrackerBinaryUtilsTest.php | 42 +++- ci/phpunit/inc/utils/CrackerUtilsTest.php | 236 ++++++++++++++++-- src/dba/models/CrackerBinary.php | 15 +- src/dba/models/CrackerBinaryFactory.php | 4 +- src/dba/models/generator.php | 3 + .../agentapi/model/DownloadBinaryAction.php | 7 + .../apiv2/helper/CreateSupertaskHelperAPI.php | 3 +- src/inc/apiv2/model/CrackerBinaryAPI.php | 59 ++++- src/inc/apiv2/openapi/SpecOverrides.php | 1 + .../CrackerBinaryDownloadHandler.php | 10 + src/inc/handlers/CrackerHandler.php | 3 +- src/inc/handlers/SupertaskHandler.php | 2 +- src/inc/handlers/TaskHandler.php | 5 + src/inc/startup/setup.json | 3 +- src/inc/user_api/UserAPICracker.php | 3 +- src/inc/user_api/UserAPITask.php | 4 +- src/inc/utils/AccessGroupUtils.php | 8 +- src/inc/utils/AccessUtils.php | 13 + src/inc/utils/CrackerBinaryUtils.php | 17 +- src/inc/utils/CrackerUtils.php | 60 ++++- src/inc/utils/HashlistUtils.php | 10 +- src/inc/utils/PretaskUtils.php | 6 +- src/inc/utils/SupertaskUtils.php | 7 +- src/inc/utils/TaskUtils.php | 7 + 37 files changed, 543 insertions(+), 66 deletions(-) diff --git a/ci/apiv2/test_cracker.py b/ci/apiv2/test_cracker.py index d98df9c23..205039872 100644 --- a/ci/apiv2/test_cracker.py +++ b/ci/apiv2/test_cracker.py @@ -145,7 +145,7 @@ def test_create_with_both_sources_rejects(self): def test_create_with_missing_source_data_rejects(self): obj = Cracker(crackerBinaryTypeId=1, version='7.2.7', binaryName='cracker', - sourceType='inline') + sourceType='inline', accessGroupId=1) with self.assertRaises(HashtopolisError) as e: obj.save() self.assertEqual(400, e.exception.status_code) diff --git a/ci/apiv2/test_logentry.py b/ci/apiv2/test_logentry.py index 3b5649c7a..6bdf8ef21 100644 --- a/ci/apiv2/test_logentry.py +++ b/ci/apiv2/test_logentry.py @@ -6,7 +6,13 @@ class LogEntryTest(BaseTest): model_class = LogEntry def test_get_one(self): - obj = LogEntry.objects.get(pk=1) + # the id of the oldest entry cannot be assumed, the server deletes the + # oldest log entries once the configured limit is exceeded + entries = LogEntry.objects.all() + if len(entries) == 0: + self.skipTest('no log entries exist yet on this database') + obj = LogEntry.objects.get(pk=entries[0].id) self.assertIsNotNone(obj) + self.assertEqual(entries[0].id, obj.id) # TODO: Create event which generate logenties and check if logentry is created diff --git a/ci/apiv2/test_taskwrapper.py b/ci/apiv2/test_taskwrapper.py index 653eb0829..12de9d20e 100644 --- a/ci/apiv2/test_taskwrapper.py +++ b/ci/apiv2/test_taskwrapper.py @@ -60,7 +60,8 @@ def test_helper_create_supertask_generic_cracker(self): crackerBinaryTypeId=crackertype.id, version='1.2.3', downloadUrl=get_cracker_archive_url(), - binaryName='generic-x64') + binaryName='generic-x64', + accessGroupId=1) cracker.save() self.delete_after_test(cracker) hashlist = self.create_hashlist() diff --git a/ci/apiv2/testfiles/cracker/create_cracker_001.json b/ci/apiv2/testfiles/cracker/create_cracker_001.json index c90c25570..724423e43 100644 --- a/ci/apiv2/testfiles/cracker/create_cracker_001.json +++ b/ci/apiv2/testfiles/cracker/create_cracker_001.json @@ -2,6 +2,6 @@ "crackerBinaryTypeId": 1, "version": "0.0.1", "downloadUrl": "https://example.org/files/cracker-0.0.1.7z", - "binaryName": "cracker" + "binaryName": "cracker", + "accessGroupId": 1 } - diff --git a/ci/apiv2/testfiles/cracker/create_cracker_002.json b/ci/apiv2/testfiles/cracker/create_cracker_002.json index 2155e3591..f380568cb 100644 --- a/ci/apiv2/testfiles/cracker/create_cracker_002.json +++ b/ci/apiv2/testfiles/cracker/create_cracker_002.json @@ -1,6 +1,6 @@ { "crackerBinaryTypeId": 1, "version": "0.0.1", - "downloadUrl": "https://example.org/files/cracker-0.0.1.7z" + "downloadUrl": "https://example.org/files/cracker-0.0.1.7z", + "accessGroupId": 1 } - diff --git a/ci/phpunit/TestBase.php b/ci/phpunit/TestBase.php index c7408e17e..da7dc5ab9 100644 --- a/ci/phpunit/TestBase.php +++ b/ci/phpunit/TestBase.php @@ -210,10 +210,10 @@ protected function createCrackerBinaryType(): CrackerBinaryType { /** * @throws Exception */ - protected function createCrackerBinary(CrackerBinaryType $crackerBinaryType): CrackerBinary { + protected function createCrackerBinary(CrackerBinaryType $crackerBinaryType, int $accessGroupId = 1): CrackerBinary { $crackerBinary = $this->createDatabaseObject( Factory::getCrackerBinaryFactory(), - new CrackerBinary(null, $crackerBinaryType->getId(), '1.0.' . uniqid(), 'https://example.invalid/' . uniqid(), 'binary_' . uniqid(), null) + new CrackerBinary(null, $crackerBinaryType->getId(), '1.0.' . uniqid(), 'https://example.invalid/' . uniqid(), 'binary_' . uniqid(), null, $accessGroupId) ); $this->assertTrue($crackerBinary instanceof CrackerBinary); return $crackerBinary; diff --git a/ci/phpunit/dba/AbstractModelFactoryTest.php b/ci/phpunit/dba/AbstractModelFactoryTest.php index 18adb7feb..f6ee9166a 100644 --- a/ci/phpunit/dba/AbstractModelFactoryTest.php +++ b/ci/phpunit/dba/AbstractModelFactoryTest.php @@ -1681,7 +1681,7 @@ private function setUpHealthCheck(): array { $crackerBinaryType = new CrackerBinaryType(null, '', 0); $crackerBinaryType = $this->createDatabaseObject(Factory::getCrackerBinaryTypeFactory(), $crackerBinaryType); - $crackerBinary = new CrackerBinary(null, $crackerBinaryType->getId(), '', '', '', null); + $crackerBinary = new CrackerBinary(null, $crackerBinaryType->getId(), '', '', '', null, 1); $crackerBinary = $this->createDatabaseObject(Factory::getCrackerBinaryFactory(), $crackerBinary); $healthCheck = new HealthCheck(null, 0, 0, 0, $hashType->getId(), $crackerBinary->getId(), 0, ''); diff --git a/ci/phpunit/dba/MassUpdateSetTest.php b/ci/phpunit/dba/MassUpdateSetTest.php index 5bbb199ca..2833ae74b 100644 --- a/ci/phpunit/dba/MassUpdateSetTest.php +++ b/ci/phpunit/dba/MassUpdateSetTest.php @@ -159,7 +159,7 @@ public function testMassSingleUpdateWithMappedColumn(): void { $agent = $this->createDatabaseObject(Factory::getAgentFactory(), new Agent(null, '', '', 0, '', '', 0, 0, 0, '', '', 0, '', null, 0, '')); $hashType = $this->createDatabaseObject(Factory::getHashTypeFactory(), new HashType(null, $prefix . '_ht', 0, 0)); $cbt = $this->createDatabaseObject(Factory::getCrackerBinaryTypeFactory(), new CrackerBinaryType(null, '', 0)); - $cb = $this->createDatabaseObject(Factory::getCrackerBinaryFactory(), new CrackerBinary(null, $cbt->getId(), '', '', '', null)); + $cb = $this->createDatabaseObject(Factory::getCrackerBinaryFactory(), new CrackerBinary(null, $cbt->getId(), '', '', '', null, 1)); $healthCheck = $this->createDatabaseObject(Factory::getHealthCheckFactory(), new HealthCheck(null, 0, 0, 0, $hashType->getId(), $cb->getId(), 0, '')); $hca1 = $this->createDatabaseObject(Factory::getHealthCheckAgentFactory(), new HealthCheckAgent(null, $healthCheck->getId(), $agent->getId(), 0, 0, 0, 0, 100, '')); diff --git a/ci/phpunit/downloadapi/DownloadAppTest.php b/ci/phpunit/downloadapi/DownloadAppTest.php index 811760523..4cf3b9e1a 100644 --- a/ci/phpunit/downloadapi/DownloadAppTest.php +++ b/ci/phpunit/downloadapi/DownloadAppTest.php @@ -4,6 +4,7 @@ use Hashtopolis\dba\Factory; use Hashtopolis\dba\models\Agent; +use Hashtopolis\dba\models\AccessGroupAgent; use Hashtopolis\dba\models\CrackerBinary; use Hashtopolis\dba\models\CrackerBinaryType; use Hashtopolis\inc\defines\DDirectories; @@ -51,20 +52,24 @@ protected function setUp(): void { ); $this->externalBinary = $this->createDatabaseObject( Factory::getCrackerBinaryFactory(), - new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://example.com/hc.7z', 'testcracker', null) + new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://example.com/hc.7z', 'testcracker', null, 1) ); // create a locally stored binary through the import source $this->agentToken = 'dl-test-' . uniqid(); - $this->createDatabaseObject( + $agent = $this->createDatabaseObject( Factory::getAgentFactory(), new Agent(null, 'download-test-agent-' . $suffix, '', 0, '', '', 0, 0, 0, $this->agentToken, '', 0, '', null, 0, '') ); + $this->createDatabaseObject( + Factory::getAccessGroupAgentFactory(), + new AccessGroupAgent(null, 1, $agent->getId()) + ); $importName = 'download-test-' . uniqid() . '.7z'; $this->archiveContent = self::SEVEN_ZIP_MAGIC . 'download-test-content'; file_put_contents(self::getImportPath() . $importName, $this->archiveContent); - $this->localBinary = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $importName); + $this->localBinary = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $importName, 1); $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $this->localBinary); if (isset($_SERVER['HTTP_RANGE'])) { diff --git a/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json b/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json index e87ef913a..2b76a1d1a 100644 --- a/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json +++ b/ci/phpunit/fixtures/openapi/crackerbinarytype.spec.json @@ -1848,7 +1848,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -1873,6 +1874,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -2230,7 +2235,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -2255,6 +2261,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -2612,7 +2622,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -2637,6 +2648,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -3046,7 +3061,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -3071,6 +3087,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } diff --git a/ci/phpunit/inc/UtilTest.php b/ci/phpunit/inc/UtilTest.php index 28eb938a0..ab7325ce8 100644 --- a/ci/phpunit/inc/UtilTest.php +++ b/ci/phpunit/inc/UtilTest.php @@ -968,7 +968,8 @@ public function testCheckOrCreateInitialObjectCrackerBinary(): void { 'version' => '7.0.0', 'downloadUrl' => 'https://example.com/test.7z', 'binaryName' => 'testHashcat', - 'filename' => null + 'filename' => null, + 'accessGroupId' => 1 ]; Util::checkOrCreateInitialObject(Factory::getCrackerBinaryFactory(), $data); $obj = Factory::getCrackerBinaryFactory()->get($id); diff --git a/ci/phpunit/inc/utils/AccessGroupUtilsTest.php b/ci/phpunit/inc/utils/AccessGroupUtilsTest.php index e91f3b6a2..74dc7adcb 100644 --- a/ci/phpunit/inc/utils/AccessGroupUtilsTest.php +++ b/ci/phpunit/inc/utils/AccessGroupUtilsTest.php @@ -7,6 +7,7 @@ use Hashtopolis\dba\models\AccessGroupAgent; use Hashtopolis\dba\models\AccessGroupUser; use Hashtopolis\dba\models\Agent; +use Hashtopolis\dba\models\CrackerBinary; use Hashtopolis\dba\models\Chunk; use Hashtopolis\dba\models\File; use Hashtopolis\dba\models\Hashlist; @@ -262,12 +263,17 @@ public function testDeleteGroupReassignsDependentEntitiesToDefaultGroup(): void $hashlist = $this->createHashlist($groupToDelete, $hashType); $taskWrapper = $this->createTaskWrapper($groupToDelete, $hashlist); $file = $this->createFile($groupToDelete); + $crackerBinary = $this->createDatabaseObject( + Factory::getCrackerBinaryFactory(), + new CrackerBinary(null, 1, '1.0.0', 'http://example.com/hc.7z', 'delgroup-cracker', null, $groupToDelete->getId()) + ); AccessGroupUtils::deleteGroup($groupToDelete->getId()); $updatedHashlist = Factory::getHashlistFactory()->get($hashlist->getId()); $updatedTaskWrapper = Factory::getTaskWrapperFactory()->get($taskWrapper->getId()); $updatedFile = Factory::getFileFactory()->get($file->getId()); + $updatedCrackerBinary = Factory::getCrackerBinaryFactory()->get($crackerBinary->getId()); $deletedGroup = Factory::getAccessGroupFactory()->get($groupToDelete->getId()); $remainingUsers = AccessGroupUtils::getUsers($groupToDelete->getId()); $remainingAgents = AccessGroupUtils::getAgents($groupToDelete->getId()); @@ -278,6 +284,8 @@ public function testDeleteGroupReassignsDependentEntitiesToDefaultGroup(): void $this->assertSame($defaultGroup->getId(), $updatedTaskWrapper->getAccessGroupId()); $this->assertInstanceOf(File::class, $updatedFile); $this->assertSame($defaultGroup->getId(), $updatedFile->getAccessGroupId()); + $this->assertInstanceOf(CrackerBinary::class, $updatedCrackerBinary); + $this->assertSame($defaultGroup->getId(), $updatedCrackerBinary->getAccessGroupId()); $this->assertNull($deletedGroup); $this->assertSame([], $remainingUsers); $this->assertSame([], $remainingAgents); diff --git a/ci/phpunit/inc/utils/AccessUtilsTest.php b/ci/phpunit/inc/utils/AccessUtilsTest.php index d0ea3f8e5..fdc9672b4 100644 --- a/ci/phpunit/inc/utils/AccessUtilsTest.php +++ b/ci/phpunit/inc/utils/AccessUtilsTest.php @@ -414,7 +414,7 @@ public function testAgentCanAccessTaskWhenWrapperHashlistAndFilesAreAllowed(): v $hashlist = $this->createHashlist($group, $hashType); $taskWrapper = $this->createTaskWrapper($group, $hashlist); $crackerBinaryType = $this->createCrackerBinaryType(); - $crackerBinary = $this->createCrackerBinary($crackerBinaryType); + $crackerBinary = $this->createCrackerBinary($crackerBinaryType, $group->getId()); $task = $this->createTask($taskWrapper, $crackerBinary, $crackerBinaryType); $file = $this->createFile($group); diff --git a/ci/phpunit/inc/utils/CrackerBinaryUtilsTest.php b/ci/phpunit/inc/utils/CrackerBinaryUtilsTest.php index d835092bf..be0de2e34 100644 --- a/ci/phpunit/inc/utils/CrackerBinaryUtilsTest.php +++ b/ci/phpunit/inc/utils/CrackerBinaryUtilsTest.php @@ -6,7 +6,10 @@ use Hashtopolis\dba\Factory; use Hashtopolis\dba\models\CrackerBinary; use Hashtopolis\dba\models\CrackerBinaryType; +use Hashtopolis\dba\models\AccessGroupUser; use Hashtopolis\inc\HTException; +use Hashtopolis\inc\utils\AccessGroupUtils; +use Hashtopolis\inc\utils\AccessUtils; use Hashtopolis\inc\utils\CrackerBinaryUtils; use Hashtopolis\TestBase; @@ -36,7 +39,7 @@ protected function setUp(): void { private function addBinary(string $version): AbstractModel { return $this->createDatabaseObject( Factory::getCrackerBinaryFactory(), - new CrackerBinary(null, $this->type->getId(), $version, 'http://example.com', 'testcracker', null) + new CrackerBinary(null, $this->type->getId(), $version, 'http://example.com', 'testcracker', null, 1) ); } @@ -75,4 +78,41 @@ public function testGetNewestVersionOutOfOrderInsertStillReturnsHighest(): void $result = CrackerBinaryUtils::getNewestVersion($this->type->getId()); $this->assertSame($newest->getId(), $result->getId()); } + + // Verifies that getNewestVersion() only considers binaries of access groups the + // user is a member of — binaries of one type can be in different groups. + public function testGetNewestVersionRespectsUserGroups(): void { + $group = $this->createAccessGroup('ag-newestversion'); + $user = $this->createUser('newestversion-user'); + + // 2.0.0 is in the default group, 1.0.0 in the group of the user + $highVersion = $this->addBinary('2.0.0'); + $lowVersion = $this->createDatabaseObject( + Factory::getCrackerBinaryFactory(), + new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://example.com', 'testcracker', null, $group->getId()) + ); + $this->createDatabaseObject( + Factory::getAccessGroupUserFactory(), + new AccessGroupUser(null, $group->getId(), $user->getId()) + ); + // createUser made the user a member of the default group too, remove it so + // only the binary in the test group is accessible + AccessGroupUtils::removeUser($user->getId(), AccessUtils::getOrCreateDefaultAccessGroup()->getId()); + + // without a user all binaries are considered + $this->assertSame($highVersion->getId(), CrackerBinaryUtils::getNewestVersion($this->type->getId())->getId()); + // the user can only access 1.0.0, so it is picked over the newer 2.0.0 + $this->assertSame($lowVersion->getId(), CrackerBinaryUtils::getNewestVersion($this->type->getId(), $user)->getId()); + + // a user without access to any binary of the type gets no version + $otherUser = $this->createUser('newestversion-other-user'); + AccessGroupUtils::removeUser($otherUser->getId(), AccessUtils::getOrCreateDefaultAccessGroup()->getId()); + try { + CrackerBinaryUtils::getNewestVersion($this->type->getId(), $otherUser); + $this->fail('Expected HTException when the user has no accessible binary'); + } + catch (HTException $e) { + $this->assertStringContainsString('No binary versions available', $e->getMessage()); + } + } } diff --git a/ci/phpunit/inc/utils/CrackerUtilsTest.php b/ci/phpunit/inc/utils/CrackerUtilsTest.php index 893a8af1b..1260240a9 100644 --- a/ci/phpunit/inc/utils/CrackerUtilsTest.php +++ b/ci/phpunit/inc/utils/CrackerUtilsTest.php @@ -7,6 +7,7 @@ use Hashtopolis\dba\QueryFilter; use Hashtopolis\dba\models\CrackerBinary; use Hashtopolis\dba\models\CrackerBinaryType; +use Hashtopolis\dba\models\AccessGroupUser; use Hashtopolis\inc\Util; use Hashtopolis\inc\defines\DDirectories; use Hashtopolis\inc\apiv2\error\HttpConflict; @@ -15,6 +16,7 @@ use Hashtopolis\inc\utils\CrackerUtils; use Hashtopolis\TestBase; use Override; +use RuntimeException; require_once(dirname(__FILE__) . '/../../TestBase.php'); require_once(dirname(__FILE__) . '/../../../../src/inc/startup/include.php'); @@ -43,7 +45,7 @@ protected function setUp(): void { ); $this->binary = $this->createDatabaseObject( Factory::getCrackerBinaryFactory(), - new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://example.com', 'testcracker', null) + new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://example.com', 'testcracker', null, 1) ); } @@ -108,7 +110,7 @@ public function testCreateBinaryTypeDuplicateNameThrowsHttpConflict(): void { // empty. Uses a valid type ID so the method reaches the field validation. public function testCreateBinaryEmptyVersionThrowsHttpError(): void { $this->expectException(HttpError::class); - CrackerUtils::createBinary('', 'testcracker', 'http://example.com', $this->type->getId()); + CrackerUtils::createBinary('', 'testcracker', 'http://example.com', $this->type->getId(), 1); } // Verifies that createBinary() rejects a download url the server cannot @@ -117,7 +119,7 @@ public function testCreateBinaryEmptyVersionThrowsHttpError(): void { public function testCreateBinaryUnreachableUrlRollsBack(): void { $countBefore = $this->countBinariesOfType($this->type->getId()); try { - CrackerUtils::createBinary('9.9.9', 'testcracker', 'http://127.0.0.1:1/cracker.7z', $this->type->getId()); + CrackerUtils::createBinary('9.9.9', 'testcracker', 'http://127.0.0.1:1/cracker.7z', $this->type->getId(), 1); $this->fail('Expected HttpError for an unreachable download url'); } catch (HttpError $e) { @@ -131,7 +133,22 @@ public function testCreateBinaryUnreachableUrlRollsBack(): void { // so the server cannot be pointed at local files or stream wrappers. public function testCreateBinaryInvalidSchemeThrowsHttpError(): void { $this->expectException(HttpError::class); - CrackerUtils::createBinary('9.9.9', 'testcracker', 'file:///etc/passwd', $this->type->getId()); + CrackerUtils::createBinary('9.9.9', 'testcracker', 'file:///etc/passwd', $this->type->getId(), 1); + } + + // Verifies the full happy path: createBinary() creates and returns a new + // CrackerBinary when all fields are valid, and downloads a local copy of the + // archive from the download url. + public function testCreateBinaryValidInputCreatesBinary(): void { + $url = $this->serveHttpFile('cracker.7z', self::SEVEN_ZIP_MAGIC . 'downloaded-content'); + $b = CrackerUtils::createBinary('9.9.9', 'newcracker', $url, $this->type->getId(), 1); + $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $b); + $this->assertSame('9.9.9', $b->getVersion()); + // the server downloaded and stored a local copy of the archive + $copy = CrackerUtils::getCrackersPath() . $b->getId() . '_test-crackerutils-type-9.9.9.7z'; + $this->assertFileExists($copy); + $this->assertEquals(self::SEVEN_ZIP_MAGIC . 'downloaded-content', file_get_contents($copy)); + CrackerUtils::deleteBinary($b->getId()); } private const SEVEN_ZIP_MAGIC = "\x37\x7A\xBC\xAF\x27\x1C"; @@ -152,7 +169,7 @@ public function testCreateBinaryFromUploadImportSource(): void { $name = 'test-archive-' . uniqid() . '.7z'; $content = self::SEVEN_ZIP_MAGIC . 'test-content'; file_put_contents($this->getImportPath() . $name, $content); - $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $name); + $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $name, 1); $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $b); $this->assertEquals('test-crackerutils-type-7.2.7.7z', $b->getFilename()); @@ -172,7 +189,7 @@ public function testCreateBinaryFromUploadImportSource(): void { // stores the base64 decoded archive in the crackers directory. public function testCreateBinaryFromUploadInlineSource(): void { $content = self::SEVEN_ZIP_MAGIC . 'inline-content'; - $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'inline', base64_encode($content)); + $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'inline', base64_encode($content), 1); $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $b); $this->assertEquals('test-crackerutils-type-7.2.7.7z', $b->getFilename()); @@ -219,7 +236,7 @@ public function testCreateBinaryFromUploadSanitizesFilename(): void { Factory::getCrackerBinaryTypeFactory(), new CrackerBinaryType(null, 'weird cracker name!', 1) ); - $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $type->getId(), 'inline', base64_encode(self::SEVEN_ZIP_MAGIC)); + $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $type->getId(), 'inline', base64_encode(self::SEVEN_ZIP_MAGIC), 1); $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $b); $this->assertEquals('weird-cracker-name--7.2.7.7z', $b->getFilename()); @@ -229,25 +246,25 @@ public function testCreateBinaryFromUploadSanitizesFilename(): void { // Verifies that createBinaryFromUpload() rejects an unsupported sourceType. public function testCreateBinaryFromUploadInvalidSourceTypeThrowsHttpError(): void { $this->expectException(HttpError::class); - CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'bogus', 'data'); + CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'bogus', 'data', 1); } // Verifies that createBinaryFromUpload() rejects an empty version. public function testCreateBinaryFromUploadEmptyVersionThrowsHttpError(): void { $this->expectException(HttpError::class); - CrackerUtils::createBinaryFromUpload('', 'testcracker', $this->type->getId(), 'inline', base64_encode(self::SEVEN_ZIP_MAGIC)); + CrackerUtils::createBinaryFromUpload('', 'testcracker', $this->type->getId(), 'inline', base64_encode(self::SEVEN_ZIP_MAGIC), 1); } // Verifies that createBinaryFromUpload() rejects missing sourceData. public function testCreateBinaryFromUploadEmptySourceDataThrowsHttpError(): void { $this->expectException(HttpError::class); - CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'inline', ''); + CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'inline', '', 1); } // Verifies that createBinaryFromUpload() rejects sourceData which is not valid base64. public function testCreateBinaryFromUploadInvalidBase64ThrowsHttpError(): void { $this->expectException(HttpError::class); - CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'inline', '!!!no-base64!!!'); + CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'inline', '!!!no-base64!!!', 1); } // Verifies that an inline archive which cannot be decoded with sufficient @@ -279,7 +296,7 @@ public function testCreateBinaryFromUploadInlineTooLargeHasNoSideEffects(): void // local files or stream wrappers can be fetched by the server. public function testCreateBinaryFromUploadUrlSchemeThrowsHttpError(): void { $this->expectException(HttpError::class); - CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'url', 'file:///etc/passwd'); + CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'url', 'file:///etc/passwd', 1); } // Verifies that a non-7z archive is rejected and the import file is restored and @@ -290,7 +307,7 @@ public function testCreateBinaryFromUploadImportNot7zRollsBack(): void { $countBefore = $this->countBinariesOfType($this->type->getId()); try { - CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $name); + CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $name, 1); $this->fail('Expected HttpError for a non-7z archive'); } catch (HttpError $e) { @@ -309,7 +326,7 @@ public function testCreateBinaryFromUploadImportFileMissingRollsBack(): void { $countBefore = $this->countBinariesOfType($this->type->getId()); try { - CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', 'does-not-exist-' . uniqid() . '.7z'); + CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', 'does-not-exist-' . uniqid() . '.7z', 1); $this->fail('Expected HttpError for a missing import file'); } catch (HttpError $e) { @@ -323,7 +340,7 @@ public function testCreateBinaryFromUploadImportFileMissingRollsBack(): void { public function testDeleteBinaryRemovesLocalArchive(): void { $name = 'test-archive-' . uniqid() . '.7z'; file_put_contents($this->getImportPath() . $name, self::SEVEN_ZIP_MAGIC . 'to-be-deleted'); - $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $name); + $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $name, 1); $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $b); $archive = CrackerUtils::getCrackersPath() . $b->getId() . '_' . $b->getFilename(); $this->assertFileExists($archive); @@ -339,7 +356,7 @@ public function testDeleteBinaryRemovesLocalArchive(): void { public function testDeleteBinaryRemovesDownloadedLocalCopy(): void { $binary = $this->createDatabaseObject( Factory::getCrackerBinaryFactory(), - new CrackerBinary(null, $this->type->getId(), '3.0.0', 'http://example.com/cracker.7z', 'testcracker', null) + new CrackerBinary(null, $this->type->getId(), '3.0.0', 'http://example.com/cracker.7z', 'testcracker', null, 1) ); $copy = CrackerUtils::getCrackersPath() . $binary->getId() . '_test-crackerutils-type-3.0.0.7z'; file_put_contents($copy, self::SEVEN_ZIP_MAGIC . 'downloaded-local-copy'); @@ -359,7 +376,7 @@ public function testDeleteBinaryTypeRemovesLocalArchives(): void { ); $name = 'test-archive-' . uniqid() . '.7z'; file_put_contents($this->getImportPath() . $name, self::SEVEN_ZIP_MAGIC . 'to-be-deleted'); - $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $type->getId(), 'import', $name); + $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $type->getId(), 'import', $name, 1); $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $b); $archive = CrackerUtils::getCrackersPath() . $b->getId() . '_' . $b->getFilename(); $this->assertFileExists($archive); @@ -374,7 +391,7 @@ public function testDeleteBinaryTypeRemovesLocalArchives(): void { public function testUpdateBinaryRejectsUrlChangeForLocalBinary(): void { $name = 'test-archive-' . uniqid() . '.7z'; file_put_contents($this->getImportPath() . $name, self::SEVEN_ZIP_MAGIC . 'local'); - $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $name); + $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $name, 1); $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $b); try { @@ -395,7 +412,7 @@ public function testUpdateBinaryRejectsUrlChangeForLocalBinary(): void { public function testUpdateBinaryAllowsUnchangedUrlForLocalBinary(): void { $name = 'test-archive-' . uniqid() . '.7z'; file_put_contents($this->getImportPath() . $name, self::SEVEN_ZIP_MAGIC . 'local'); - $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $name); + $b = CrackerUtils::createBinaryFromUpload('7.2.7', 'testcracker', $this->type->getId(), 'import', $name, 1); $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $b); CrackerUtils::updateBinary('8.0.0', 'testcracker', $b->getDownloadUrl(), $b->getId()); @@ -412,7 +429,7 @@ public function testUpdateBinaryAllowsUnchangedUrlForLocalBinary(): void { public function testUpdateBinaryUrlChangeRollsBackOnFailedDownload(): void { $binary = $this->createDatabaseObject( Factory::getCrackerBinaryFactory(), - new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://127.0.0.1:1/original.7z', 'testcracker', null) + new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://127.0.0.1:1/original.7z', 'testcracker', null, 1) ); $copy = CrackerUtils::getCrackersPath() . $binary->getId() . '_test-crackerutils-type-1.0.0.7z'; file_put_contents($copy, self::SEVEN_ZIP_MAGIC . 'previous-local-copy'); @@ -433,6 +450,7 @@ public function testUpdateBinaryUrlChangeRollsBackOnFailedDownload(): void { // the previous local copy is still there, unchanged $this->assertFileExists($copy); $this->assertEquals(self::SEVEN_ZIP_MAGIC . 'previous-local-copy', file_get_contents($copy)); + CrackerUtils::deleteBinary($binary->getId()); } // Verifies that a changed download url of a url-referenced binary has to be @@ -455,7 +473,7 @@ public function testUpdateBinaryUrlChangeInvalidSchemeThrowsHttpError(): void { public function testUpdateBinaryUnchangedUrlKeepsLocalCopy(): void { $binary = $this->createDatabaseObject( Factory::getCrackerBinaryFactory(), - new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://127.0.0.1:1/unreachable.7z', 'testcracker', null) + new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://127.0.0.1:1/unreachable.7z', 'testcracker', null, 1) ); $copy = CrackerUtils::getCrackersPath() . $binary->getId() . '_test-crackerutils-type-1.0.0.7z'; file_put_contents($copy, self::SEVEN_ZIP_MAGIC . 'kept-local-copy'); @@ -469,5 +487,181 @@ public function testUpdateBinaryUnchangedUrlKeepsLocalCopy(): void { // the local copy is untouched, still under the filename of its version $this->assertFileExists($copy); $this->assertEquals(self::SEVEN_ZIP_MAGIC . 'kept-local-copy', file_get_contents($copy)); + CrackerUtils::deleteBinary($binary->getId()); + } + + // Verifies that binaries can only be created in access groups the user is a + // member of. + public function testCreateBinaryRequiresGroupMembership(): void { + $group = $this->createAccessGroup('ag-crackerutils-member'); + $user = $this->createUser('crackerutils-member-user'); + $url = $this->serveHttpFile('cracker.7z', self::SEVEN_ZIP_MAGIC . 'content'); + + try { + CrackerUtils::createBinary('1.0.0', 'testcracker', $url, $this->type->getId(), $group->getId(), $user); + $this->fail('Expected HttpError when the user is not a member of the access group'); + } + catch (HttpError $e) { + $this->assertStringContainsString('no rights', $e->getMessage()); + } + + $this->createDatabaseObject( + Factory::getAccessGroupUserFactory(), + new AccessGroupUser(null, $group->getId(), $user->getId()) + ); + $binary = CrackerUtils::createBinary('1.0.0', 'testcracker', $url, $this->type->getId(), $group->getId(), $user); + $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $binary); + $this->assertEquals($group->getId(), $binary->getAccessGroupId()); + // also removes the downloaded local copy of the archive + CrackerUtils::deleteBinary($binary->getId()); + } + + // Verifies that uploads can only be created in access groups the user is a + // member of. + public function testCreateBinaryFromUploadRequiresGroupMembership(): void { + $group = $this->createAccessGroup('ag-crackerutils-upload'); + $user = $this->createUser('crackerutils-upload-user'); + + try { + CrackerUtils::createBinaryFromUpload('1.0.0', 'testcracker', $this->type->getId(), 'inline', + base64_encode(self::SEVEN_ZIP_MAGIC . 'content'), $group->getId(), $user); + $this->fail('Expected HttpError when the user is not a member of the access group'); + } + catch (HttpError $e) { + $this->assertStringContainsString('no rights', $e->getMessage()); + } + $this->assertEmpty(glob(CrackerUtils::getCrackersPath() . '*_test-crackerutils-type-1.0.0.7z')); + + $this->createDatabaseObject( + Factory::getAccessGroupUserFactory(), + new AccessGroupUser(null, $group->getId(), $user->getId()) + ); + $binary = CrackerUtils::createBinaryFromUpload('1.0.0', 'testcracker', $this->type->getId(), 'inline', + base64_encode(self::SEVEN_ZIP_MAGIC . 'content'), $group->getId(), $user); + $this->registerDatabaseObject(Factory::getCrackerBinaryFactory(), $binary); + $this->assertEquals($group->getId(), $binary->getAccessGroupId()); + unlink(CrackerUtils::getCrackersPath() . $binary->getId() . '_' . $binary->getFilename()); + } + + // Verifies that creation with a group that does not exist is rejected. + public function testCreateBinaryRejectsInvalidGroup(): void { + try { + CrackerUtils::createBinary('1.0.0', 'testcracker', 'http://example.com/hc.7z', $this->type->getId(), 99999999, $this->adminUser); + $this->fail('Expected HttpError for a non existing access group'); + } + catch (HttpError $e) { + $this->assertStringContainsString('Invalid access group', $e->getMessage()); + } + } + + // Verifies that moving a binary to another group requires membership of the + // current and of the new group. + public function testChangeAccessGroupRequiresMembershipOfBothGroups(): void { + $group1 = $this->createAccessGroup('ag-crackerutils-move-1'); + $group2 = $this->createAccessGroup('ag-crackerutils-move-2'); + $user = $this->createUser('crackerutils-move-user'); + $this->createDatabaseObject( + Factory::getAccessGroupUserFactory(), + new AccessGroupUser(null, $group1->getId(), $user->getId()) + ); + $binary = $this->createDatabaseObject( + Factory::getCrackerBinaryFactory(), + new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://example.com/hc.7z', 'testcracker', null, $group1->getId()) + ); + + // the user is not a member of the new group + try { + CrackerUtils::changeAccessGroup($binary->getId(), $group2->getId(), $user); + $this->fail('Expected HttpError when the user is not a member of the new group'); + } + catch (HttpError $e) { + $this->assertStringContainsString('No access to this group', $e->getMessage()); + } + + $otherUser = $this->createUser('crackerutils-move-user-2'); + $this->createDatabaseObject( + Factory::getAccessGroupUserFactory(), + new AccessGroupUser(null, $group2->getId(), $otherUser->getId()) + ); + // the other user is not a member of the current group of the binary + try { + CrackerUtils::changeAccessGroup($binary->getId(), $group2->getId(), $otherUser); + $this->fail('Expected HttpError when the user is not a member of the current group'); + } + catch (HttpError $e) { + $this->assertStringContainsString('No access to this group', $e->getMessage()); + } + + $this->assertEquals($group1->getId(), Factory::getCrackerBinaryFactory()->get($binary->getId())->getAccessGroupId()); + } + + // Verifies that a binary can be moved to another group the user is a member of. + public function testChangeAccessGroupMovesBinary(): void { + $group1 = $this->createAccessGroup('ag-crackerutils-move-ok-1'); + $group2 = $this->createAccessGroup('ag-crackerutils-move-ok-2'); + $user = $this->createUser('crackerutils-move-ok-user'); + $this->createDatabaseObject( + Factory::getAccessGroupUserFactory(), + new AccessGroupUser(null, $group1->getId(), $user->getId()) + ); + $this->createDatabaseObject( + Factory::getAccessGroupUserFactory(), + new AccessGroupUser(null, $group2->getId(), $user->getId()) + ); + $binary = $this->createDatabaseObject( + Factory::getCrackerBinaryFactory(), + new CrackerBinary(null, $this->type->getId(), '1.0.0', 'http://example.com/hc.7z', 'testcracker', null, $group1->getId()) + ); + + CrackerUtils::changeAccessGroup($binary->getId(), $group2->getId(), $user); + + $this->assertEquals($group2->getId(), Factory::getCrackerBinaryFactory()->get($binary->getId())->getAccessGroupId()); + } + + private array $httpFileServers = []; + + /** + * Serves a file with the given content through a local HTTP server, so tests + * can use a working download url without external network access. The server + * runs until tearDown() shuts it down. + */ + private function serveHttpFile(string $filename, string $content): string { + // pick a free port + $socket = stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr); + $address = stream_socket_get_name($socket, false); + fclose($socket); + $docroot = sys_get_temp_dir() . '/hashtopolis-http-' . uniqid(); + mkdir($docroot); + file_put_contents($docroot . '/' . $filename, $content); + $proc = proc_open( + ['php', '-S', $address, '-t', $docroot], + [['pipe', 'r'], ['pipe', 'w'], ['pipe', 'w']], + $pipes + ); + // wait until the server responds + $deadline = microtime(true) + 10; + while (microtime(true) < $deadline) { + $conn = @fsockopen('127.0.0.1', parse_url('http://' . $address, PHP_URL_PORT), $errno, $errstr, 0.2); + if ($conn !== false) { + fclose($conn); + $this->httpFileServers[] = ['proc' => $proc, 'docroot' => $docroot]; + return 'http://' . $address . '/' . $filename; + } + usleep(50000); + } + throw new RuntimeException('Local HTTP file server did not come up in time'); + } + + protected function tearDown(): void { + foreach ($this->httpFileServers as $server) { + proc_terminate($server['proc']); + proc_close($server['proc']); + foreach (glob($server['docroot'] . '/*') ?: [] as $path) { + unlink($path); + } + rmdir($server['docroot']); + } + $this->httpFileServers = []; + parent::tearDown(); } } diff --git a/src/dba/models/CrackerBinary.php b/src/dba/models/CrackerBinary.php index 55c01cfb5..012325e2d 100644 --- a/src/dba/models/CrackerBinary.php +++ b/src/dba/models/CrackerBinary.php @@ -11,14 +11,16 @@ class CrackerBinary extends AbstractModel { private ?string $downloadUrl; private ?string $binaryName; private ?string $filename; + private ?int $accessGroupId; - function __construct(?int $crackerBinaryId, ?int $crackerBinaryTypeId, ?string $version, ?string $downloadUrl, ?string $binaryName, ?string $filename) { + function __construct(?int $crackerBinaryId, ?int $crackerBinaryTypeId, ?string $version, ?string $downloadUrl, ?string $binaryName, ?string $filename, ?int $accessGroupId) { $this->crackerBinaryId = $crackerBinaryId; $this->crackerBinaryTypeId = $crackerBinaryTypeId; $this->version = $version; $this->downloadUrl = $downloadUrl; $this->binaryName = $binaryName; $this->filename = $filename; + $this->accessGroupId = $accessGroupId; } function getKeyValueDict(): array { @@ -29,6 +31,7 @@ function getKeyValueDict(): array { $dict['downloadUrl'] = $this->downloadUrl; $dict['binaryName'] = $this->binaryName; $dict['filename'] = $this->filename; + $dict['accessGroupId'] = $this->accessGroupId; return $dict; } @@ -41,6 +44,7 @@ static function getFeatures(): array { $dict['downloadUrl'] = ['read_only' => False, "type" => "str(255)", "subtype" => "unset", "choices" => "unset", "null" => True, "pk" => False, "protected" => False, "private" => False, "alias" => "downloadUrl", "public" => False, "dba_mapping" => False]; $dict['binaryName'] = ['read_only' => False, "type" => "str(50)", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => False, "private" => False, "alias" => "binaryName", "public" => False, "dba_mapping" => False]; $dict['filename'] = ['read_only' => True, "type" => "str(100)", "subtype" => "unset", "choices" => "unset", "null" => True, "pk" => False, "protected" => True, "private" => False, "alias" => "filename", "public" => False, "dba_mapping" => False]; + $dict['accessGroupId'] = ['read_only' => False, "type" => "int", "subtype" => "unset", "choices" => "unset", "null" => False, "pk" => False, "protected" => False, "private" => False, "alias" => "accessGroupId", "public" => False, "dba_mapping" => False]; return $dict; } @@ -109,12 +113,21 @@ function setFilename(?string $filename): void { $this->filename = $filename; } + function getAccessGroupId(): ?int { + return $this->accessGroupId; + } + + function setAccessGroupId(?int $accessGroupId): void { + $this->accessGroupId = $accessGroupId; + } + const CRACKER_BINARY_ID = "crackerBinaryId"; const CRACKER_BINARY_TYPE_ID = "crackerBinaryTypeId"; const VERSION = "version"; const DOWNLOAD_URL = "downloadUrl"; const BINARY_NAME = "binaryName"; const FILENAME = "filename"; + const ACCESS_GROUP_ID = "accessGroupId"; const PERM_CREATE = "permCrackerBinaryCreate"; const PERM_READ = "permCrackerBinaryRead"; diff --git a/src/dba/models/CrackerBinaryFactory.php b/src/dba/models/CrackerBinaryFactory.php index 459d29922..8428804c2 100644 --- a/src/dba/models/CrackerBinaryFactory.php +++ b/src/dba/models/CrackerBinaryFactory.php @@ -32,7 +32,7 @@ function getCacheValidTime(): int { * @return CrackerBinary */ function getNullObject(): CrackerBinary { - return new CrackerBinary(-1, null, null, null, null, null); + return new CrackerBinary(-1, null, null, null, null, null, null); } /** @@ -45,6 +45,6 @@ function createObjectFromDict(array $dict): CrackerBinary { $conv[strtolower($key)] = $val; } $dict = $conv; - return new CrackerBinary($dict['crackerbinaryid'], $dict['crackerbinarytypeid'], $dict['version'], $dict['downloadurl'], $dict['binaryname'], $dict['filename']); + return new CrackerBinary($dict['crackerbinaryid'], $dict['crackerbinarytypeid'], $dict['version'], $dict['downloadurl'], $dict['binaryname'], $dict['filename'], $dict['accessgroupid']); } } diff --git a/src/dba/models/generator.php b/src/dba/models/generator.php index 8103c4095..92aa1abc9 100644 --- a/src/dba/models/generator.php +++ b/src/dba/models/generator.php @@ -288,6 +288,9 @@ ['name' => 'binaryName', 'read_only' => False, 'type' => 'str(50)'], // archive filename of a server-hosted binary; NULL means the binary is downloaded from downloadUrl ['name' => 'filename', 'read_only' => True, 'null' => True, 'type' => 'str(100)', 'protected' => True], + // access group the binary belongs to, required on creation and patchable with + // membership validation of the current and the new group + ['name' => 'accessGroupId', 'read_only' => False, 'type' => 'int', 'relation' => 'AccessGroup'], ], ]; $CONF['CrackerBinaryType'] = [ diff --git a/src/inc/agentapi/model/DownloadBinaryAction.php b/src/inc/agentapi/model/DownloadBinaryAction.php index 884b3f4b2..fb44ddac5 100644 --- a/src/inc/agentapi/model/DownloadBinaryAction.php +++ b/src/inc/agentapi/model/DownloadBinaryAction.php @@ -17,6 +17,7 @@ use Hashtopolis\inc\defines\DServerLog; use Hashtopolis\inc\SConfig; use Hashtopolis\inc\Util; +use Hashtopolis\inc\utils\AccessUtils; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface as Request; use Slim\Psr7\Response; @@ -66,6 +67,12 @@ public function __invoke(Request $request, Response $response): ResponseInterfac if ($crackerBinary === null) { return $this->error($response, PActions::DOWNLOAD_BINARY, 'Invalid cracker binary type id!'); } + if (!in_array( + $crackerBinary->getAccessGroupId(), + Util::arrayOfIds(AccessUtils::getAccessGroupsOfAgent($agent)) + )) { + return $this->error($response, PActions::DOWNLOAD_BINARY, 'No access to this cracker binary!'); + } $crackerBinaryType = Factory::getCrackerBinaryTypeFactory()->get($crackerBinary->getCrackerBinaryTypeId()); DServerLog::log(DServerLog::TRACE, 'Agent ' . $agent->getId() . ' downloaded cracker binary ' . $crackerBinary->getId()); $ext = Util::getFileExtension($agent->getOs()); diff --git a/src/inc/apiv2/helper/CreateSupertaskHelperAPI.php b/src/inc/apiv2/helper/CreateSupertaskHelperAPI.php index 824b83db9..cf8d19039 100644 --- a/src/inc/apiv2/helper/CreateSupertaskHelperAPI.php +++ b/src/inc/apiv2/helper/CreateSupertaskHelperAPI.php @@ -67,7 +67,8 @@ public function actionPost($data): AbstractModel|array|null { SupertaskUtils::runSupertask( $supertaskTemplate->getId(), $hashlist->getId(), - $crackerBinary->getId() + $crackerBinary->getId(), + $this->getCurrentUser() ); /* Quick to retrieve newly created TaskWrapper */ diff --git a/src/inc/apiv2/model/CrackerBinaryAPI.php b/src/inc/apiv2/model/CrackerBinaryAPI.php index 156ad12e4..cbff0db9c 100644 --- a/src/inc/apiv2/model/CrackerBinaryAPI.php +++ b/src/inc/apiv2/model/CrackerBinaryAPI.php @@ -2,17 +2,24 @@ namespace Hashtopolis\inc\apiv2\model; +use Exception; use Hashtopolis\dba\AbstractModel; +use Hashtopolis\dba\ContainFilter; +use Hashtopolis\dba\Factory; use Hashtopolis\inc\utils\CrackerUtils; +use Hashtopolis\inc\utils\AccessUtils; use Hashtopolis\dba\models\CrackerBinary; +use Hashtopolis\dba\models\AccessGroup; use Hashtopolis\dba\models\CrackerBinaryType; use Hashtopolis\dba\models\Task; +use Hashtopolis\dba\models\User; use Hashtopolis\inc\apiv2\common\AbstractModelAPI; use Hashtopolis\inc\apiv2\error\HttpError; use Hashtopolis\inc\apiv2\error\HttpForbidden; use Hashtopolis\inc\apiv2\error\ResourceNotFoundError; use Hashtopolis\inc\HTException; +use Hashtopolis\inc\Util; /** @@ -27,6 +34,31 @@ public static function getDBAclass(): string { return CrackerBinary::class; } + /** + * @param CrackerBinary $object + * @throws Exception + */ + protected function getSingleACL(User $user, AbstractModel $object): bool { + return in_array( + $object->getAccessGroupId(), + Util::arrayOfIds(AccessUtils::getAccessGroupsOfUser($user)) + ); + } + + /** + * @throws Exception + */ + protected function getFilterACL(): array { + return [ + Factory::FILTER => [ + new ContainFilter( + CrackerBinary::ACCESS_GROUP_ID, + Util::arrayOfIds(AccessUtils::getAccessGroupsOfUser($this->getCurrentUser())) + ) + ] + ]; + } + /** * Extra fields which are valid for creation of object. With one of the source * fields given, the archive of the cracker binary is uploaded to the server @@ -52,6 +84,12 @@ public static function getToOneRelationships(): array { 'relationType' => CrackerBinaryType::class, 'relationKey' => CrackerBinaryType::CRACKER_BINARY_TYPE_ID, ], + 'accessGroup' => [ + 'key' => CrackerBinary::ACCESS_GROUP_ID, + + 'relationType' => AccessGroup::class, + 'relationKey' => AccessGroup::ACCESS_GROUP_ID, + ], ]; } @@ -83,7 +121,9 @@ protected function createObject(array $data): int { $data[CrackerBinary::BINARY_NAME], $data[CrackerBinary::CRACKER_BINARY_TYPE_ID], $data["sourceType"], - $data["sourceData"] + $data["sourceData"], + $data[CrackerBinary::ACCESS_GROUP_ID], + $this->getCurrentUser() ); return $binary->getId(); } @@ -94,7 +134,9 @@ protected function createObject(array $data): int { $data[CrackerBinary::VERSION], $data[CrackerBinary::BINARY_NAME], $data[CrackerBinary::DOWNLOAD_URL], - $data[CrackerBinary::CRACKER_BINARY_TYPE_ID] + $data[CrackerBinary::CRACKER_BINARY_TYPE_ID], + $data[CrackerBinary::ACCESS_GROUP_ID], + $this->getCurrentUser() ); return $binary->getId(); } @@ -107,6 +149,19 @@ protected function deleteObject(AbstractModel $object): void { CrackerUtils::deleteBinary($object->getId()); } + /** + * The access group of a binary can be changed, but only to a group the user is + * also a member of (and only from a group the user has access to). + * + * @param int $id + * @param User $current_user + */ + protected function getUpdateHandlers($id, $current_user): array { + return [ + CrackerBinary::ACCESS_GROUP_ID => fn($value) => CrackerUtils::changeAccessGroup($id, $value, $current_user) + ]; + } + /** * The download url of locally stored binaries is owned by the server, so it * cannot be overwritten with a patch. When the download url of a binary which diff --git a/src/inc/apiv2/openapi/SpecOverrides.php b/src/inc/apiv2/openapi/SpecOverrides.php index ccce41901..56ee33a90 100644 --- a/src/inc/apiv2/openapi/SpecOverrides.php +++ b/src/inc/apiv2/openapi/SpecOverrides.php @@ -120,6 +120,7 @@ public static function defaults(): self { 'CrackerBinary' => [ self::ATTRIBUTE_DESCRIPTIONS => [ 'downloadUrl' => 'External http/https url where the agent downloads the binary archive from. The server keeps a local copy of the archive for later analysis: on creation it is downloaded from this url, and changing the url re-downloads it from the new url. The creation or change is rejected if that download fails or the archive is not a valid 7z file. Mutually exclusive with sourceType: when the archive is uploaded with sourceType, this url is set automatically to the download endpoint of this server and cannot be changed afterwards.', + 'accessGroupId' => 'Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups.', 'filename' => 'Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided.', 'sourceType' => 'Source the 7z archive is uploaded from: inline (base64 archive data in sourceData), import (filename of a file in the import directory as sourceData) or url (http/https url in sourceData, fetched by the server). Mutually exclusive with downloadUrl.', 'sourceData' => 'Source of the archive upload, depending on sourceType: base64 encoded archive data, filename of a file in the import directory or a http/https url to fetch the archive from.', diff --git a/src/inc/downloadapi/CrackerBinaryDownloadHandler.php b/src/inc/downloadapi/CrackerBinaryDownloadHandler.php index 72fb0c03f..1b0c34d97 100644 --- a/src/inc/downloadapi/CrackerBinaryDownloadHandler.php +++ b/src/inc/downloadapi/CrackerBinaryDownloadHandler.php @@ -4,10 +4,13 @@ use Hashtopolis\dba\Factory; use Hashtopolis\dba\models\Agent; +use Hashtopolis\dba\models\User; use Hashtopolis\inc\agentapi\common\AgentAction; use Hashtopolis\inc\defines\DServerLog; use Hashtopolis\inc\utils\CrackerUtils; use Hashtopolis\inc\utils\DownloadUtils; +use Hashtopolis\inc\utils\AccessUtils; +use Hashtopolis\inc\Util; use Psr\Http\Message\ResponseInterface as Response; use Psr\Http\Message\ServerRequestInterface as Request; @@ -28,9 +31,16 @@ public function __invoke(Request $request, Response $response, array $args): Res $agent = $request->getAttribute(AgentAction::AGENT_ATTRIBUTE); if ($agent instanceof Agent) { + if (!in_array($binary->getAccessGroupId(), Util::arrayOfIds(AccessUtils::getAccessGroupsOfAgent($agent)))) { + return $response->withStatus(403); + } DServerLog::log(DServerLog::TRACE, 'Agent ' . $agent->getId() . ' downloaded the archive of cracker binary ' . $binary->getId()); } else { + $user = Factory::getUserFactory()->get((int)$request->getAttribute('userId')); + if (!$user instanceof User || !AccessUtils::userCanAccessCrackerBinary($binary, $user)) { + return $response->withStatus(403); + } DServerLog::log(DServerLog::TRACE, 'User ' . ($request->getAttribute('userId') ?? 'unknown') . ' downloaded the archive of cracker binary ' . $binary->getId()); } diff --git a/src/inc/handlers/CrackerHandler.php b/src/inc/handlers/CrackerHandler.php index 64e3dd203..c455dceec 100644 --- a/src/inc/handlers/CrackerHandler.php +++ b/src/inc/handlers/CrackerHandler.php @@ -3,6 +3,7 @@ namespace Hashtopolis\inc\handlers; use Hashtopolis\inc\utils\AccessControl; +use Hashtopolis\inc\utils\AccessUtils; use Hashtopolis\inc\utils\CrackerUtils; use Throwable; use Hashtopolis\inc\defines\DCrackerBinaryAction; @@ -32,7 +33,7 @@ public function handle($action): void { die(); case DCrackerBinaryAction::CREATE_BINARY: AccessControl::getInstance()->checkPermission(DCrackerBinaryAction::CREATE_BINARY_PERM); - $binary = CrackerUtils::createBinary($_POST['version'], $_POST['name'], $_POST['url'], $_POST['binaryTypeId']); + $binary = CrackerUtils::createBinary($_POST['version'], $_POST['name'], $_POST['url'], $_POST['binaryTypeId'], AccessUtils::getOrCreateDefaultAccessGroup()->getId(), AccessControl::getInstance()->getUser()); header("Location: crackers.php?id=" . $binary->getCrackerBinaryTypeId()); die(); case DCrackerBinaryAction::EDIT_BINARY: diff --git a/src/inc/handlers/SupertaskHandler.php b/src/inc/handlers/SupertaskHandler.php index ba69a3e95..6aa334f1b 100644 --- a/src/inc/handlers/SupertaskHandler.php +++ b/src/inc/handlers/SupertaskHandler.php @@ -27,7 +27,7 @@ public function handle($action): void { break; case DSupertaskAction::APPLY_SUPERTASK: AccessControl::getInstance()->checkPermission(DSupertaskAction::APPLY_SUPERTASK_PERM); - SupertaskUtils::runSupertask($_POST['supertask'], $_POST['hashlist'], $_POST['crackerBinaryVersionId']); + SupertaskUtils::runSupertask($_POST['supertask'], $_POST['hashlist'], $_POST['crackerBinaryVersionId'], Login::getInstance()->getUser()); header("Location: tasks.php"); die(); case DSupertaskAction::IMPORT_SUPERTASK: diff --git a/src/inc/handlers/TaskHandler.php b/src/inc/handlers/TaskHandler.php index 14c6c86c8..871e9310d 100644 --- a/src/inc/handlers/TaskHandler.php +++ b/src/inc/handlers/TaskHandler.php @@ -4,6 +4,7 @@ use Exception; use Hashtopolis\inc\utils\AccessControl; +use Hashtopolis\inc\utils\AccessUtils; use Hashtopolis\inc\DataSet; use Throwable; use Hashtopolis\inc\utils\FileDownloadUtils; @@ -237,6 +238,10 @@ private function create() { UI::addMessage(UI::ERROR, "Non-matching cracker binary selection!"); return; } + else if (!AccessUtils::userCanAccessCrackerBinary($crackerBinary, Login::getInstance()->getUser())) { + UI::addMessage(UI::ERROR, "No access to this cracker binary!"); + return; + } else if ($chunk < 0 || $status < 0 || $chunk < $status) { UI::addMessage(UI::ERROR, "Chunk time must be higher than status timer!"); return; diff --git a/src/inc/startup/setup.json b/src/inc/startup/setup.json index 9fc9d5326..0e9cedc5b 100644 --- a/src/inc/startup/setup.json +++ b/src/inc/startup/setup.json @@ -394,7 +394,8 @@ "version" : "7.1.2", "downloadUrl" : "https://hashcat.net/files/hashcat-7.1.2.7z", "binaryName" : "hashcat", - "filename" : null + "filename" : null, + "accessGroupId" : 1 } ], "CrackerBinaryType" : [ diff --git a/src/inc/user_api/UserAPICracker.php b/src/inc/user_api/UserAPICracker.php index fedf9e936..88fdc818d 100644 --- a/src/inc/user_api/UserAPICracker.php +++ b/src/inc/user_api/UserAPICracker.php @@ -2,6 +2,7 @@ namespace Hashtopolis\inc\user_api; +use Hashtopolis\inc\utils\AccessUtils; use Hashtopolis\inc\utils\CrackerUtils; use Throwable; use Hashtopolis\inc\defines\UQuery; @@ -68,7 +69,7 @@ private function addVersion($QUERY) { throw new HTException("Invalid query!"); } $cracker = CrackerUtils::getBinaryType($QUERY[UQueryCracker::CRACKER_ID]); - CrackerUtils::createBinary($QUERY[UQueryCracker::BINARY_VERSION], $QUERY[UQueryCracker::BINARY_NAME], $QUERY[UQueryCracker::BINARY_URL], $cracker->getId()); + CrackerUtils::createBinary($QUERY[UQueryCracker::BINARY_VERSION], $QUERY[UQueryCracker::BINARY_NAME], $QUERY[UQueryCracker::BINARY_URL], $cracker->getId(), AccessUtils::getOrCreateDefaultAccessGroup()->getId(), $this->user); $this->sendSuccessResponse($QUERY); } diff --git a/src/inc/user_api/UserAPITask.php b/src/inc/user_api/UserAPITask.php index 6e77f6692..ccfeeb840 100644 --- a/src/inc/user_api/UserAPITask.php +++ b/src/inc/user_api/UserAPITask.php @@ -350,7 +350,7 @@ private function runSupertask($QUERY) { if (!isset($QUERY[UQueryTask::SUPERTASK_ID]) || !isset($QUERY[UQueryTask::TASK_HASHLIST]) || !isset($QUERY[UQueryTask::TASK_CRACKER_VERSION])) { throw new HTException("Invalid query!"); } - SupertaskUtils::runSupertask($QUERY[UQueryTask::SUPERTASK_ID], $QUERY[UQueryTask::TASK_HASHLIST], $QUERY[UQueryTask::TASK_CRACKER_VERSION]); + SupertaskUtils::runSupertask($QUERY[UQueryTask::SUPERTASK_ID], $QUERY[UQueryTask::TASK_HASHLIST], $QUERY[UQueryTask::TASK_CRACKER_VERSION], $this->user); $this->sendSuccessResponse($QUERY); } @@ -362,7 +362,7 @@ private function runPretask($QUERY) { if (!isset($QUERY[UQueryTask::PRETASK_ID]) || !isset($QUERY[UQueryTask::TASK_HASHLIST]) || !isset($QUERY[UQueryTask::TASK_CRACKER_VERSION])) { throw new HTException("Invalid query!"); } - PretaskUtils::runPretask($QUERY[UQueryTask::PRETASK_ID], $QUERY[UQueryTask::TASK_HASHLIST], $QUERY[UQueryTask::TASK_NAME], $QUERY[UQueryTask::TASK_CRACKER_VERSION]); + PretaskUtils::runPretask($QUERY[UQueryTask::PRETASK_ID], $QUERY[UQueryTask::TASK_HASHLIST], $QUERY[UQueryTask::TASK_NAME], $QUERY[UQueryTask::TASK_CRACKER_VERSION], $this->user); $this->sendSuccessResponse($QUERY); } diff --git a/src/inc/utils/AccessGroupUtils.php b/src/inc/utils/AccessGroupUtils.php index d2fd8cedb..32cbe4b3d 100644 --- a/src/inc/utils/AccessGroupUtils.php +++ b/src/inc/utils/AccessGroupUtils.php @@ -5,6 +5,7 @@ use Exception; use Hashtopolis\dba\models\AccessGroup; use Hashtopolis\dba\models\Chunk; +use Hashtopolis\dba\models\CrackerBinary; use Hashtopolis\dba\ContainFilter; use Hashtopolis\dba\models\TaskWrapper; use Hashtopolis\dba\UpdateSet; @@ -215,7 +216,12 @@ public static function deleteGroup(int $groupId): void { $qF = new QueryFilter(File::ACCESS_GROUP_ID, $group->getId(), "="); $uS = new UpdateSet(File::ACCESS_GROUP_ID, $default->getId()); Factory::getFileFactory()->massUpdate([Factory::FILTER => $qF, Factory::UPDATE => $uS]); - + + // update associations of cracker binaries with this group + $qF = new QueryFilter(CrackerBinary::ACCESS_GROUP_ID, $group->getId(), "="); + $uS = new UpdateSet(CrackerBinary::ACCESS_GROUP_ID, $default->getId()); + Factory::getCrackerBinaryFactory()->massUpdate([Factory::FILTER => $qF, Factory::UPDATE => $uS]); + // delete all associations to users $qF = new QueryFilter(AccessGroupUser::ACCESS_GROUP_ID, $group->getId(), "="); Factory::getAccessGroupUserFactory()->massDeletion([Factory::FILTER => $qF]); diff --git a/src/inc/utils/AccessUtils.php b/src/inc/utils/AccessUtils.php index 8c075b2f9..ecb625b4f 100644 --- a/src/inc/utils/AccessUtils.php +++ b/src/inc/utils/AccessUtils.php @@ -14,6 +14,7 @@ use Hashtopolis\dba\models\Hashlist; use Hashtopolis\dba\Factory; use Hashtopolis\dba\models\File; +use Hashtopolis\dba\models\CrackerBinary; use Hashtopolis\dba\models\Task; use Hashtopolis\inc\apiv2\common\AbstractBaseAPI; use Hashtopolis\inc\Util; @@ -119,6 +120,13 @@ public static function userCanAccessFile(File $file, User $user): bool { } return true; } + + /** + * @throws Exception + */ + public static function userCanAccessCrackerBinary(CrackerBinary $binary, User $user): bool { + return in_array($binary->getAccessGroupId(), Util::getAccessGroupIds($user->getId())); + } /** * @param $accessGroupsAgent AccessGroup[] @@ -197,6 +205,11 @@ public static function agentCanAccessTask(Agent $agent, Task $task): bool { if (!in_array($taskWrapper->getAccessGroupId(), $accessGroupsIds)) { return false; // task is in an access group which agent is not allowed to access } + + $crackerBinary = Factory::getCrackerBinaryFactory()->get($task->getCrackerBinaryId()); + if ($crackerBinary === null || !in_array($crackerBinary->getAccessGroupId(), $accessGroupsIds)) { + return false; + } $hashlists = Util::checkSuperHashlist(Factory::getHashlistFactory()->get($taskWrapper->getHashlistId())); foreach ($hashlists as $hashlist) { diff --git a/src/inc/utils/CrackerBinaryUtils.php b/src/inc/utils/CrackerBinaryUtils.php index 77aa2e8c0..a9e1a20b8 100644 --- a/src/inc/utils/CrackerBinaryUtils.php +++ b/src/inc/utils/CrackerBinaryUtils.php @@ -5,20 +5,31 @@ use Exception; use Hashtopolis\dba\models\CrackerBinary; use Hashtopolis\dba\QueryFilter; +use Hashtopolis\dba\ContainFilter; use Hashtopolis\dba\Factory; +use Hashtopolis\dba\models\User; use Composer\Semver\Comparator; use Hashtopolis\inc\HTException; +use Hashtopolis\inc\Util; class CrackerBinaryUtils { /** + * Returns the newest version of a cracker binary type. When a user is given, only + * binaries of access groups the user is a member of are considered. + * * @param int $crackerBinaryTypeId + * @param User|null $user * @return CrackerBinary|null * @throws HTException * @throws Exception */ - public static function getNewestVersion(int $crackerBinaryTypeId): ?CrackerBinary { - $qF = new QueryFilter(CrackerBinary::CRACKER_BINARY_TYPE_ID, $crackerBinaryTypeId, "="); - $binaries = Factory::getCrackerBinaryFactory()->filter([Factory::FILTER => $qF]); + public static function getNewestVersion(int $crackerBinaryTypeId, ?User $user = null): ?CrackerBinary { + $qFs = [new QueryFilter(CrackerBinary::CRACKER_BINARY_TYPE_ID, $crackerBinaryTypeId, "=")]; + if ($user !== null) { + // only binaries of access groups the user is a member of can be used + $qFs[] = new ContainFilter(CrackerBinary::ACCESS_GROUP_ID, Util::arrayOfIds(AccessUtils::getAccessGroupsOfUser($user))); + } + $binaries = Factory::getCrackerBinaryFactory()->filter([Factory::FILTER => $qFs]); /** @var ?CrackerBinary $newest */ $newest = null; foreach ($binaries as $binary) { diff --git a/src/inc/utils/CrackerUtils.php b/src/inc/utils/CrackerUtils.php index 39b2bb325..4f75730c6 100644 --- a/src/inc/utils/CrackerUtils.php +++ b/src/inc/utils/CrackerUtils.php @@ -10,6 +10,7 @@ use Hashtopolis\dba\ContainFilter; use Hashtopolis\dba\Factory; use Hashtopolis\dba\models\Pretask; +use Hashtopolis\dba\models\User; use Hashtopolis\inc\defines\DDirectories; use Hashtopolis\inc\apiv2\error\HttpConflict; use Hashtopolis\inc\apiv2\error\HttpError; @@ -68,20 +69,23 @@ public static function createBinaryType(string $typeName): CrackerBinaryType { * @param string $name * @param string $url * @param int $binaryTypeId + * @param int $accessGroupId access group the binary belongs to + * @param User|null $user if given, the user must be a member of the access group * @return CrackerBinary * @throws HttpError * @throws HTException * @throws Exception */ - public static function createBinary(string $version, string $name, string $url, int $binaryTypeId): CrackerBinary { + public static function createBinary(string $version, string $name, string $url, int $binaryTypeId, int $accessGroupId, ?User $user = null): CrackerBinary { $binaryType = CrackerUtils::getBinaryType($binaryTypeId); if (strlen($version) == 0 || strlen($name) == 0 || strlen($url) == 0) { throw new HttpError("Please provide all information!"); } + CrackerUtils::checkAccessGroup($accessGroupId, $user); CrackerUtils::validateDownloadUrl($url); // create the entry first, the id is needed for the filename of the local copy $binary = Factory::getCrackerBinaryFactory()->save( - new CrackerBinary(null, $binaryType->getId(), $version, $url, $name, null) + new CrackerBinary(null, $binaryType->getId(), $version, $url, $name, null, $accessGroupId) ); try { CrackerUtils::storeLocalCopy($binary); @@ -103,16 +107,19 @@ public static function createBinary(string $version, string $name, string $url, * @param int $binaryTypeId * @param string $sourceType choices inline, import, url * @param string $sourceData base64 data, filename in the import directory or download url + * @param int $accessGroupId access group the binary belongs to + * @param User|null $user if given, the user must be a member of the access group * @return CrackerBinary * @throws HttpError * @throws HTException * @throws Exception */ - public static function createBinaryFromUpload(string $version, string $name, int $binaryTypeId, string $sourceType, string $sourceData): CrackerBinary { + public static function createBinaryFromUpload(string $version, string $name, int $binaryTypeId, string $sourceType, string $sourceData, int $accessGroupId, ?User $user = null): CrackerBinary { $binaryType = CrackerUtils::getBinaryType($binaryTypeId); if (strlen($version) == 0 || strlen($name) == 0 || strlen($sourceData) == 0) { throw new HttpError("Please provide all information!"); } + CrackerUtils::checkAccessGroup($accessGroupId, $user); // determine the source of the archive and validate it switch ($sourceType) { @@ -149,7 +156,7 @@ public static function createBinaryFromUpload(string $version, string $name, int // create the entry first with a placeholder download url, the final one // contains the id and can only be set once it is known $binary = Factory::getCrackerBinaryFactory()->save( - new CrackerBinary(null, $binaryType->getId(), $version, "", $name, null) + new CrackerBinary(null, $binaryType->getId(), $version, "", $name, null, $accessGroupId) ); $target = CrackerUtils::getCrackersPath() . $binary->getId() . '_' . $filename; @@ -434,6 +441,51 @@ public static function updateBinary(string $version, string $name, string $url, return Factory::getCrackerBinaryTypeFactory()->get($binary->getCrackerBinaryTypeId()); } + /** + * Ensures the access group exists and the user is a member of it, so binaries + * can only be created by members of the group they are created in. Callers + * without a group input (legacy UI or user api) use the default access group. + * + * @throws HttpError + * @throws Exception + */ + private static function checkAccessGroup(int $accessGroupId, ?User $user): void { + $accessGroup = Factory::getAccessGroupFactory()->get($accessGroupId); + if ($accessGroup === null) { + throw new HttpError("Invalid access group selected!"); + } + if ($user !== null && sizeof(AccessUtils::intersection( + array($accessGroup), AccessUtils::getAccessGroupsOfUser($user))) == 0) { + throw new HttpError("Access group with no rights selected!"); + } + } + + /** + * Moves a cracker binary to another access group. The user must be a member of + * the current and of the new access group. + * + * @param int $binaryId + * @param int $accessGroupId + * @param User $user + * @throws HttpError + * @throws HTException + * @throws Exception + */ + public static function changeAccessGroup(int $binaryId, int $accessGroupId, User $user): void { + $binary = CrackerUtils::getBinary($binaryId); + if (Factory::getAccessGroupFactory()->get($accessGroupId) === null) { + throw new HttpError("Invalid access group selected!"); + } + $userAccessGroupIds = Util::getAccessGroupIds($user->getId()); + if (!in_array($accessGroupId, $userAccessGroupIds) || !in_array($binary->getAccessGroupId(), $userAccessGroupIds)) { + throw new HttpError("No access to this group!"); + } + if ($binary->getAccessGroupId() == $accessGroupId) { + return; + } + Factory::getCrackerBinaryFactory()->set($binary, CrackerBinary::ACCESS_GROUP_ID, $accessGroupId); + } + /** * @param int $binaryTypeId * @return CrackerBinaryType diff --git a/src/inc/utils/HashlistUtils.php b/src/inc/utils/HashlistUtils.php index 4590911f5..fff865da5 100644 --- a/src/inc/utils/HashlistUtils.php +++ b/src/inc/utils/HashlistUtils.php @@ -131,6 +131,14 @@ public static function applyPreconfTasks(int $hashlistId, array $pretasks, User foreach ($pretasks as $pretask) { $task = Factory::getPretaskFactory()->get($pretask); if ($task != null) { + // skip pretasks of which the user has no accessible binary version, the + // newest version of the cracker type in one of the groups of the user is used + try { + $crackerBinaryId = CrackerBinaryUtils::getNewestVersion($task->getCrackerBinaryTypeId(), $user)->getId(); + } + catch (HTException $e) { + continue; + } if ($hashlist->getHexSalt() == 1 && !str_contains($task->getAttackCmd(), "--hex-salt")) { $task->setAttackCmd("--hex-salt " . $task->getAttackCmd()); } @@ -157,7 +165,7 @@ public static function applyPreconfTasks(int $hashlistId, array $pretasks, User $task->getIsCpuTask(), $task->getUseNewBench(), 0, - CrackerBinaryUtils::getNewestVersion($task->getCrackerBinaryTypeId())->getId(), + $crackerBinaryId, $task->getCrackerBinaryTypeId(), $taskWrapper->getId(), 0, diff --git a/src/inc/utils/PretaskUtils.php b/src/inc/utils/PretaskUtils.php index 16a9a2462..7b4a103bc 100644 --- a/src/inc/utils/PretaskUtils.php +++ b/src/inc/utils/PretaskUtils.php @@ -8,6 +8,7 @@ use Hashtopolis\dba\models\FilePretask; use Hashtopolis\dba\models\TaskWrapper; use Hashtopolis\dba\models\Task; +use Hashtopolis\dba\models\User; use Hashtopolis\dba\OrderFilter; use Hashtopolis\dba\QueryFilter; use Hashtopolis\dba\models\SupertaskPretask; @@ -237,7 +238,7 @@ public static function getPretask(int $pretaskId): Pretask { * @throws HTException * @throws Exception */ - public static function runPretask(int $pretaskId, int $hashlistId, string $name, int $crackerBinaryId): void { + public static function runPretask(int $pretaskId, int $hashlistId, string $name, int $crackerBinaryId, User $user): void { $pretask = Factory::getPretaskFactory()->get($pretaskId); if ($pretask == null) { throw new HTException("Invalid preconfigured task ID!"); @@ -257,6 +258,9 @@ public static function runPretask(int $pretaskId, int $hashlistId, string $name, else if ($pretask->getCrackerBinaryTypeId() != $cracker->getCrackerBinaryTypeId()) { throw new HTException("Provided cracker does not match the type of the pretask!"); } + else if (!AccessUtils::userCanAccessCrackerBinary($cracker, $user)) { + throw new HTException("You have no access to this cracker binary!"); + } Factory::getAgentFactory()->getDB()->beginTransaction(); $taskWrapper = new TaskWrapper(null, $pretask->getPriority(), $pretask->getMaxAgents(), DTaskTypes::NORMAL, $hashlist->getId(), $hashlist->getAccessGroupId(), "", 0, 0); diff --git a/src/inc/utils/SupertaskUtils.php b/src/inc/utils/SupertaskUtils.php index c31b2b1ec..b944954df 100644 --- a/src/inc/utils/SupertaskUtils.php +++ b/src/inc/utils/SupertaskUtils.php @@ -259,7 +259,7 @@ public static function getSupertask(int $supertaskId): Supertask { * @throws HTException * @throws Exception */ - public static function runSupertask(int $supertaskId, int $hashlistId, int $crackerId): void { + public static function runSupertask(int $supertaskId, int $hashlistId, int $crackerId, User $user): void { $supertask = Factory::getSupertaskFactory()->get($supertaskId); if ($supertask == null) { throw new HTException("Invalid supertask ID!"); @@ -275,6 +275,9 @@ public static function runSupertask(int $supertaskId, int $hashlistId, int $crac if ($cracker == null) { throw new HTException("Invalid cracker ID!"); } + else if (!AccessUtils::userCanAccessCrackerBinary($cracker, $user)) { + throw new HTException("You have no access to this cracker binary!"); + } $qF = new QueryFilter(SupertaskPretask::SUPERTASK_ID, $supertask->getId(), "=", Factory::getSupertaskPretaskFactory()); $jF = new JoinFilter(Factory::getSupertaskPretaskFactory(), Pretask::PRETASK_ID, SupertaskPretask::PRETASK_ID); $joined = Factory::getPretaskFactory()->filter([Factory::FILTER => $qF, Factory::JOIN => $jF]); @@ -297,7 +300,7 @@ public static function runSupertask(int $supertaskId, int $hashlistId, int $crac foreach ($pretasks as $pretask) { $crackerBinaryId = $cracker->getId(); if ($cracker->getCrackerBinaryTypeId() != $pretask->getCrackerBinaryTypeId()) { - $crackerBinaryId = CrackerBinaryUtils::getNewestVersion($pretask->getCrackerBinaryTypeId())->getId(); + $crackerBinaryId = CrackerBinaryUtils::getNewestVersion($pretask->getCrackerBinaryTypeId(), $user)->getId(); } $task = new Task( diff --git a/src/inc/utils/TaskUtils.php b/src/inc/utils/TaskUtils.php index 2afa8ba28..420045a57 100644 --- a/src/inc/utils/TaskUtils.php +++ b/src/inc/utils/TaskUtils.php @@ -897,6 +897,9 @@ public static function createTask(int $hashlistId, string $name, string $attackC if ($cracker == null) { throw new HttpError("Invalid cracker ID!"); } + else if (!AccessUtils::userCanAccessCrackerBinary($cracker, $user)) { + throw new HttpForbidden("You have no access to this cracker binary!"); + } else if (!str_contains($attackCmd, SConfig::getInstance()->getVal(DConfig::HASHLIST_ALIAS))) { throw new HttpError("Attack command does not contain hashlist alias!"); } @@ -1103,6 +1106,10 @@ private static function getCandidateTasks(Agent $agent, array $accessGroups, Tas $permitted = false; } } + $crackerBinary = Factory::getCrackerBinaryFactory()->get($task->getCrackerBinaryId()); + if ($crackerBinary === null || !in_array($crackerBinary->getAccessGroupId(), $accessGroups)) { + continue; + } if (!$permitted) { continue; // at least one of the files required for this task is secret and the agent not, so this task cannot be used } From fb62e61c5277aacc16e27a74894de73fe896c69e Mon Sep 17 00:00:00 2001 From: s3inlc Date: Thu, 17 Sep 2026 15:05:55 +0200 Subject: [PATCH 2/6] added migrations --- .../mysql/20260917150500_cracker-access-groups.sql | 8 ++++++++ .../postgres/20260917150500_cracker-access-groups.sql | 8 ++++++++ 2 files changed, 16 insertions(+) create mode 100644 src/migrations/mysql/20260917150500_cracker-access-groups.sql create mode 100644 src/migrations/postgres/20260917150500_cracker-access-groups.sql diff --git a/src/migrations/mysql/20260917150500_cracker-access-groups.sql b/src/migrations/mysql/20260917150500_cracker-access-groups.sql new file mode 100644 index 000000000..96cdb5659 --- /dev/null +++ b/src/migrations/mysql/20260917150500_cracker-access-groups.sql @@ -0,0 +1,8 @@ +-- Cracker binaries belong to an access group. Existing rows are assigned to the +-- default access group. +ALTER TABLE CrackerBinary ADD COLUMN accessGroupId int NOT NULL DEFAULT 1 AFTER filename; +ALTER TABLE CrackerBinary ADD KEY `accessGroupId` (`accessGroupId`); +ALTER TABLE CrackerBinary ADD CONSTRAINT `CrackerBinary_ibfk_2` FOREIGN KEY (`accessGroupId`) REFERENCES `AccessGroup` (`accessGroupId`); + +-- the access group must be provided explicitly, no silent default for new rows +ALTER TABLE CrackerBinary ALTER accessGroupId DROP DEFAULT; diff --git a/src/migrations/postgres/20260917150500_cracker-access-groups.sql b/src/migrations/postgres/20260917150500_cracker-access-groups.sql new file mode 100644 index 000000000..0cdc649f5 --- /dev/null +++ b/src/migrations/postgres/20260917150500_cracker-access-groups.sql @@ -0,0 +1,8 @@ +-- Cracker binaries belong to an access group. Existing rows are assigned to the +-- default access group. +ALTER TABLE CrackerBinary ADD COLUMN accessGroupId INT NOT NULL DEFAULT 1; +CREATE INDEX IF NOT EXISTS crackerbinary_accessgroupid_idx ON CrackerBinary(accessGroupId); +ALTER TABLE ONLY CrackerBinary ADD CONSTRAINT crackerbinary_ibfk_2 FOREIGN KEY (accessGroupId) REFERENCES AccessGroup(accessGroupId); + +-- the access group must be provided explicitly, no silent default for new rows +ALTER TABLE CrackerBinary ALTER COLUMN accessGroupId DROP DEFAULT; From 6f559d201aa264bc414c92f338d5627c49ed1831 Mon Sep 17 00:00:00 2001 From: s3inlc Date: Thu, 17 Sep 2026 15:26:57 +0200 Subject: [PATCH 3/6] updated openapi.json --- openapi.json | 344 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 327 insertions(+), 17 deletions(-) diff --git a/openapi.json b/openapi.json index e7314caa1..fad504052 100644 --- a/openapi.json +++ b/openapi.json @@ -7792,14 +7792,15 @@ "type": "string", "enum": [ "crackerBinaryType", + "accessGroup", "tasks" ] } }, - "description": "Relationships to include in the response, comma seperated. Possible options: crackerBinaryType, tasks", + "description": "Relationships to include in the response, comma seperated. Possible options: crackerBinaryType, accessGroup, tasks", "example": [ "crackerBinaryType", - "tasks" + "accessGroup" ] } ], @@ -8652,14 +8653,15 @@ "type": "string", "enum": [ "crackerBinaryType", + "accessGroup", "tasks" ] } }, - "description": "Relationships to include in the response, comma seperated. Possible options: crackerBinaryType, tasks", + "description": "Relationships to include in the response, comma seperated. Possible options: crackerBinaryType, accessGroup, tasks", "example": [ "crackerBinaryType", - "tasks" + "accessGroup" ] } ], @@ -41490,12 +41492,17 @@ }, "binaryName": { "type": "string" + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } }, "required": [ "crackerBinaryTypeId", "version", - "binaryName" + "binaryName", + "accessGroupId" ] } } @@ -41522,6 +41529,9 @@ "attributes": { "type": "object", "properties": { + "accessGroupId": { + "type": "integer" + }, "binaryName": { "type": "string" }, @@ -41567,6 +41577,9 @@ "attributes": { "type": "object", "properties": { + "accessGroupId": { + "type": "integer" + }, "binaryName": { "type": "string" }, @@ -41680,7 +41693,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -41705,6 +41719,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } }, @@ -41723,10 +41741,60 @@ "relationships": { "type": "object", "required": [ + "accessGroup", "crackerBinaryType", "tasks" ], "properties": { + "accessGroup": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackers/relationships/accessGroup" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackers/accessGroup" + } + } + }, + "data": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "accessGroup" + }, + "id": { + "type": "integer", + "example": 1 + } + } + }, + { + "type": "null" + } + ] + } + } + }, "crackerBinaryType": { "type": "object", "required": [ @@ -41865,6 +41933,35 @@ } } }, + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "accessGroup" + }, + "attributes": { + "type": "object", + "required": [ + "groupName" + ], + "properties": { + "groupName": { + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ @@ -42066,7 +42163,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -42091,6 +42189,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } }, @@ -42109,10 +42211,60 @@ "relationships": { "type": "object", "required": [ + "accessGroup", "crackerBinaryType", "tasks" ], "properties": { + "accessGroup": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackers/relationships/accessGroup" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackers/accessGroup" + } + } + }, + "data": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "accessGroup" + }, + "id": { + "type": "integer", + "example": 1 + } + } + }, + { + "type": "null" + } + ] + } + } + }, "crackerBinaryType": { "type": "object", "required": [ @@ -42251,6 +42403,35 @@ } } }, + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "accessGroup" + }, + "attributes": { + "type": "object", + "required": [ + "groupName" + ], + "properties": { + "groupName": { + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ @@ -42503,7 +42684,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -42528,6 +42710,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } }, @@ -42546,10 +42732,60 @@ "relationships": { "type": "object", "required": [ + "accessGroup", "crackerBinaryType", "tasks" ], "properties": { + "accessGroup": { + "type": "object", + "required": [ + "links" + ], + "properties": { + "links": { + "type": "object", + "required": [ + "self", + "related" + ], + "properties": { + "self": { + "type": "string", + "default": "/api/v2/ui/crackers/relationships/accessGroup" + }, + "related": { + "type": "string", + "default": "/api/v2/ui/crackers/accessGroup" + } + } + }, + "data": { + "oneOf": [ + { + "type": "object", + "required": [ + "type", + "id" + ], + "properties": { + "type": { + "type": "string", + "const": "accessGroup" + }, + "id": { + "type": "integer", + "example": 1 + } + } + }, + { + "type": "null" + } + ] + } + } + }, "crackerBinaryType": { "type": "object", "required": [ @@ -42689,6 +42925,35 @@ } } }, + { + "type": "object", + "required": [ + "id", + "type", + "attributes" + ], + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "type": { + "type": "string", + "const": "accessGroup" + }, + "attributes": { + "type": "object", + "required": [ + "groupName" + ], + "properties": { + "groupName": { + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ @@ -43290,7 +43555,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -43315,6 +43581,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -43672,7 +43942,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -43697,6 +43968,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -44106,7 +44381,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -44131,6 +44407,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -53522,7 +53802,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -53547,6 +53828,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -53988,7 +54273,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -54013,6 +54299,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -54506,7 +54796,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -54531,6 +54822,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -62019,7 +62314,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -62044,6 +62340,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -62994,7 +63294,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -63019,6 +63320,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } @@ -64021,7 +64326,8 @@ "version", "downloadUrl", "binaryName", - "filename" + "filename", + "accessGroupId" ], "properties": { "crackerBinaryTypeId": { @@ -64046,6 +64352,10 @@ "null" ], "description": "Filename of the locally stored 7z archive, null when the binary is downloaded from the downloadUrl. Cannot be provided." + }, + "accessGroupId": { + "type": "integer", + "description": "Access group containing this cracker binary. Required on creation; the requesting user must belong to the group. It can be changed only when the user belongs to both the current and new groups." } } } From 8484b4a7f5fba565991b9965e70991a661ba6fd4 Mon Sep 17 00:00:00 2001 From: s3inlc Date: Wed, 23 Sep 2026 12:29:46 +0200 Subject: [PATCH 4/6] fixed crackerutils test after downstack changes --- ci/phpunit/inc/utils/CrackerUtilsTest.php | 46 ++++++++++++----------- 1 file changed, 24 insertions(+), 22 deletions(-) diff --git a/ci/phpunit/inc/utils/CrackerUtilsTest.php b/ci/phpunit/inc/utils/CrackerUtilsTest.php index 1260240a9..fa93b0e96 100644 --- a/ci/phpunit/inc/utils/CrackerUtilsTest.php +++ b/ci/phpunit/inc/utils/CrackerUtilsTest.php @@ -31,6 +31,7 @@ final class CrackerUtilsTest extends TestBase { private ?AbstractModel $type = null; private ?AbstractModel $binary = null; private string|false $savedBackendUrl = false; + private array $httpFileServers = []; // Creates a CrackerBinaryType and one CrackerBinary before each test. // These records provide valid IDs for the "happy path" tests and a known @@ -52,14 +53,27 @@ protected function setUp(): void { #[Override] protected function tearDown(): void { try { - parent::tearDown(); + foreach ($this->httpFileServers as $server) { + proc_terminate($server['proc']); + proc_close($server['proc']); + foreach (glob($server['docroot'] . '/*') ?: [] as $path) { + unlink($path); + } + rmdir($server['docroot']); + } + $this->httpFileServers = []; } finally { - if ($this->savedBackendUrl === false) { - putenv('HASHTOPOLIS_BACKEND_URL'); + try { + parent::tearDown(); } - else { - putenv('HASHTOPOLIS_BACKEND_URL=' . $this->savedBackendUrl); + finally { + if ($this->savedBackendUrl === false) { + putenv('HASHTOPOLIS_BACKEND_URL'); + } + else { + putenv('HASHTOPOLIS_BACKEND_URL=' . $this->savedBackendUrl); + } } } } @@ -210,7 +224,7 @@ public function testCreateBinaryFromUploadInvalidBackendUrlHasNoSideEffects(): v putenv('HASHTOPOLIS_BACKEND_URL=ftp://configured.example/api/v2'); try { try { - CrackerUtils::createBinaryFromUpload('8.8.8', 'testcracker', $this->type->getId(), 'inline', base64_encode(self::SEVEN_ZIP_MAGIC)); + CrackerUtils::createBinaryFromUpload('8.8.8', 'testcracker', $this->type->getId(), 'inline', base64_encode(self::SEVEN_ZIP_MAGIC), 1); $this->fail('Expected invalid backend URL configuration to reject the upload'); } catch (\Exception $e) { @@ -278,7 +292,7 @@ public function testCreateBinaryFromUploadInlineTooLargeHasNoSideEffects(): void $this->markTestSkipped('memory_limit cannot be changed in this environment'); } try { - CrackerUtils::createBinaryFromUpload('8.8.8', 'testcracker', $this->type->getId(), 'inline', base64_encode(self::SEVEN_ZIP_MAGIC)); + CrackerUtils::createBinaryFromUpload('8.8.8', 'testcracker', $this->type->getId(), 'inline', base64_encode(self::SEVEN_ZIP_MAGIC), 1); $this->fail('Expected the inline archive to exceed the safe memory allowance'); } catch (HttpError $e) { @@ -521,6 +535,8 @@ public function testCreateBinaryRequiresGroupMembership(): void { public function testCreateBinaryFromUploadRequiresGroupMembership(): void { $group = $this->createAccessGroup('ag-crackerutils-upload'); $user = $this->createUser('crackerutils-upload-user'); + $archivePattern = CrackerUtils::getCrackersPath() . '*_test-crackerutils-type-1.0.0.7z'; + $archivesBefore = glob($archivePattern) ?: []; try { CrackerUtils::createBinaryFromUpload('1.0.0', 'testcracker', $this->type->getId(), 'inline', @@ -530,7 +546,7 @@ public function testCreateBinaryFromUploadRequiresGroupMembership(): void { catch (HttpError $e) { $this->assertStringContainsString('no rights', $e->getMessage()); } - $this->assertEmpty(glob(CrackerUtils::getCrackersPath() . '*_test-crackerutils-type-1.0.0.7z')); + $this->assertSame($archivesBefore, glob($archivePattern) ?: []); $this->createDatabaseObject( Factory::getAccessGroupUserFactory(), @@ -618,8 +634,6 @@ public function testChangeAccessGroupMovesBinary(): void { $this->assertEquals($group2->getId(), Factory::getCrackerBinaryFactory()->get($binary->getId())->getAccessGroupId()); } - private array $httpFileServers = []; - /** * Serves a file with the given content through a local HTTP server, so tests * can use a working download url without external network access. The server @@ -652,16 +666,4 @@ private function serveHttpFile(string $filename, string $content): string { throw new RuntimeException('Local HTTP file server did not come up in time'); } - protected function tearDown(): void { - foreach ($this->httpFileServers as $server) { - proc_terminate($server['proc']); - proc_close($server['proc']); - foreach (glob($server['docroot'] . '/*') ?: [] as $path) { - unlink($path); - } - rmdir($server['docroot']); - } - $this->httpFileServers = []; - parent::tearDown(); - } } From c5375af98814be1c556176355d5761bdaa774a3b Mon Sep 17 00:00:00 2001 From: s3inlc Date: Wed, 23 Sep 2026 14:41:04 +0200 Subject: [PATCH 5/6] check access for cracker binaries on include on it --- ci/apiv2/test_permissions.py | 48 ++++++++++++++++ src/inc/apiv2/common/AbstractBaseAPI.php | 6 +- src/inc/apiv2/common/AbstractModelAPI.php | 59 +++++++++++++++++--- src/inc/apiv2/model/CrackerBinaryTypeAPI.php | 1 + src/inc/apiv2/model/HealthCheckAPI.php | 1 + src/inc/apiv2/model/TaskAPI.php | 2 +- src/inc/apiv2/model/UserAPI.php | 3 +- 7 files changed, 107 insertions(+), 13 deletions(-) diff --git a/ci/apiv2/test_permissions.py b/ci/apiv2/test_permissions.py index 26d0d6200..e054e05a7 100644 --- a/ci/apiv2/test_permissions.py +++ b/ci/apiv2/test_permissions.py @@ -1403,6 +1403,54 @@ def test_api_token_cracker_types_include_versions_reports_missing_cracker_binary self.assertIn('permCrackerBinaryRead', json.dumps(body['meta'])) self.assertNotIn('crackerBinary', {item['type'] for item in body.get('included', [])}) + def test_api_token_cracker_types_include_versions_enforces_cracker_access_group(self): + """Cracker version includes enforce the related binary's access-group ACL.""" + cracker_type = self.create_crackertype() + cracker = self.create_cracker(extra_payload={'crackerBinaryTypeId': cracker_type.id}) + permissions = { + 'permJwtApiKeyCreate': True, + 'permCrackerBinaryTypeRead': True, + 'permCrackerBinaryRead': True, + } + auth = create_restricted_user(self, permissions) + token = create_apitoken_raw(self, auth, [ + 'permCrackerBinaryTypeRead', + 'permCrackerBinaryRead', + ]) + + response = request_with_api_token(token.token, self._cracker_types_list_query_path(cracker_type)) + self.assertEqual(response.status_code, 200, response.text) + body = response.json() + self.assertEqual(body['data'][0]['relationships']['crackerVersions']['data'], []) + included = {(item['type'], item['id']) for item in body.get('included', [])} + self.assertNotIn(('crackerBinary', cracker.id), included) + + def test_api_token_health_check_include_enforces_cracker_access_group(self): + """To-one cracker includes hide binaries outside the user's access groups.""" + cracker = self.create_cracker() + health_check = self.create_healthcheck(extra_payload={'crackerBinaryId': cracker.id}) + permissions = { + 'permJwtApiKeyCreate': True, + 'permHealthCheckRead': True, + 'permCrackerBinaryRead': True, + } + auth = create_restricted_user(self, permissions) + token = create_apitoken_raw(self, auth, [ + 'permHealthCheckRead', + 'permCrackerBinaryRead', + ]) + path = ( + f'/ui/healthchecks?include=crackerBinary' + f'&filter[healthCheckId__eq]={health_check.id}&page[size]=1' + ) + + response = request_with_api_token(token.token, path) + self.assertEqual(response.status_code, 200, response.text) + body = response.json() + self.assertIsNone(body['data'][0]['relationships']['crackerBinary']['data']) + included = {(item['type'], item['id']) for item in body.get('included', [])} + self.assertNotIn(('crackerBinary', cracker.id), included) + def test_api_token_high_value_helpers_report_each_missing_required_scope(self): """High-value helper endpoints enforce every declared required permission. diff --git a/src/inc/apiv2/common/AbstractBaseAPI.php b/src/inc/apiv2/common/AbstractBaseAPI.php index 41b59231d..4321c25c0 100644 --- a/src/inc/apiv2/common/AbstractBaseAPI.php +++ b/src/inc/apiv2/common/AbstractBaseAPI.php @@ -942,7 +942,9 @@ protected function object2Array(AbstractModel $object, array $expands = []): arr $expandResult = []; foreach ($expands as $expand) { $apiClass = $this->container->get('classMapper')->get(get_class($object)); - $expandResult[$expand] = $apiClass::fetchExpandObjects([$object], $expand); + $apiClassObject = new $apiClass($this->container); + $apiClassObject->setCurrentUser($this->getCurrentUser()); + $expandResult[$expand] = $apiClassObject->fetchVisibleExpandObjects([$object], $expand); } return $this->applyExpansions($object, $expands, $expandResult); @@ -1785,7 +1787,7 @@ protected static function getOneResource(AbstractModelAPI $apiClass, AbstractMod $expandResult = []; foreach ($expands as $expand) { // mapping from $objectId -> result objects in - $expandResult[$expand] = $apiClass->fetchExpandObjects($objects, $expand); + $expandResult[$expand] = $apiClass->fetchVisibleExpandObjects($objects, $expand); } /* Convert objects to JSON:API */ diff --git a/src/inc/apiv2/common/AbstractModelAPI.php b/src/inc/apiv2/common/AbstractModelAPI.php index b9c9bb7a0..e51e6ec26 100644 --- a/src/inc/apiv2/common/AbstractModelAPI.php +++ b/src/inc/apiv2/common/AbstractModelAPI.php @@ -61,7 +61,7 @@ public static function getExpandables(): array { * @throws HttpError * @throws Exception */ - protected static function fetchExpandObjects(array $objects, string $expand): array { + protected static function fetchExpandObjects(array $objects, string $expand, array $relationFilters = []): array { //disabled the check because with intermediate objects its possible to fetch a different model /* Ensure we receive the proper type */ // $baseModel = static::getDBAClass(); @@ -90,6 +90,7 @@ protected static function fetchExpandObjects(array $objects, string $expand): ar $toOneRelationships[$expand]['key'], $relationFactory, $toOneRelationships[$expand]['relationKey'], + $relationFilters, ); } @@ -115,11 +116,34 @@ protected static function fetchExpandObjects(array $objects, string $expand): ar $toManyRelationships[$expand]['key'], $relationFactory, $toManyRelationships[$expand]['relationKey'], + $relationFilters, ); } throw new InternalError("Internal error: Expansion '$expand' not implemented!"); } + + /** + * Fetch related objects, optionally applying the related API's list ACL. + * + * @throws ContainerExceptionInterface + * @throws NotFoundExceptionInterface + * @throws HttpError + * @throws Exception + */ + final protected function fetchVisibleExpandObjects(array $objects, string $expand): array { + $relationships = array_merge(static::getToOneRelationships(), static::getToManyRelationships()); + $relationship = $relationships[$expand]; + $relationFilters = []; + if ($relationship['filterACL'] ?? false) { + $relationClass = $relationship['relationType']; + $relationApiClass = new ($this->container->get('classMapper')->get($relationClass))($this->container); + $relationApiClass->setCurrentUser($this->getCurrentUser()); + $relationFilters = $relationApiClass->getFilterACL(); + } + + return static::fetchExpandObjects($objects, $expand, $relationFilters); + } /** @@ -244,7 +268,8 @@ final protected static function getForeignKeyRelation( array $objects, string $objectField, AbstractModelFactory $factory, - string $filterField + string $filterField, + array $relationFilters = [], ): array { $retval = array(); @@ -255,7 +280,14 @@ final protected static function getForeignKeyRelation( $objectIds[] = $kv[$objectField]; } $qF = new ContainFilter($filterField, $objectIds, $factory); - $hO = $factory->filter([Factory::FILTER => $qF]); + $relationFilters[Factory::FILTER] = array_merge( + [$qF], + $relationFilters[Factory::FILTER] ?? [] + ); + $hO = $factory->filter($relationFilters); + if (isset($relationFilters[Factory::JOIN])) { + $hO = $hO[$factory->getModelName()]; + } /* Objects are uniquely identified by fields, create mapping to speed-up further processing */ $f2o = []; @@ -289,7 +321,8 @@ final protected static function getManyToOneRelation( array $objects, string $objectField, object $factory, - string $filterField + string $filterField, + array $relationFilters = [], ): array { assert($factory instanceof AbstractModelFactory); $retval = array(); @@ -301,7 +334,14 @@ final protected static function getManyToOneRelation( $objectIds[] = $kv[$objectField]; } $qF = new ContainFilter($filterField, $objectIds, $factory); - $hO = $factory->filter([Factory::FILTER => $qF]); + $relationFilters[Factory::FILTER] = array_merge( + [$qF], + $relationFilters[Factory::FILTER] ?? [] + ); + $hO = $factory->filter($relationFilters); + if (isset($relationFilters[Factory::JOIN])) { + $hO = $hO[$factory->getModelName()]; + } /* Map (multiple) objects to base objects */ foreach ($hO as $relationObject) { @@ -802,7 +842,7 @@ public static function getManyResources(object $apiClass, Request $request, Resp $expandResult = []; foreach ($expands as $expand) { // mapping from $objectId -> result objects in - $expandResult[$expand] = $apiClass->fetchExpandObjects($objects, $expand); + $expandResult[$expand] = $apiClass->fetchVisibleExpandObjects($objects, $expand); } /* Convert objects to JSON:API */ @@ -1357,7 +1397,10 @@ public function getToOneRelatedResource(Request $request, Response $response, ar } // Relation object - $relationObjects = $this->fetchExpandObjects([$object], $relation); + $relationObjects = $this->fetchVisibleExpandObjects([$object], $relation); + if (!array_key_exists($id, $relationObjects)) { + throw new HttpForbidden("No access to this object!", 403); + } $relationObject = $relationObjects[$id]; $relationClass = $relationMapper['relationType']; @@ -1584,7 +1627,7 @@ public function getToManyRelationshipLink(Request $request, Response $response, // Base object -> Relationship objects $object = $this->doFetch($args['id']); - $expandObjects = $this->fetchExpandObjects([$object], $args['relation']); + $expandObjects = $this->fetchVisibleExpandObjects([$object], $args['relation']); $dataResources = []; if (array_key_exists($object->getId(), $expandObjects)) { diff --git a/src/inc/apiv2/model/CrackerBinaryTypeAPI.php b/src/inc/apiv2/model/CrackerBinaryTypeAPI.php index 5beb68e87..1dd1e79c4 100644 --- a/src/inc/apiv2/model/CrackerBinaryTypeAPI.php +++ b/src/inc/apiv2/model/CrackerBinaryTypeAPI.php @@ -34,6 +34,7 @@ public static function getToManyRelationships(): array { 'relationType' => CrackerBinary::class, 'relationKey' => CrackerBinary::CRACKER_BINARY_TYPE_ID, + 'filterACL' => true, ], 'tasks' => [ 'key' => CrackerBinaryType::CRACKER_BINARY_TYPE_ID, diff --git a/src/inc/apiv2/model/HealthCheckAPI.php b/src/inc/apiv2/model/HealthCheckAPI.php index 085437878..005ad1589 100644 --- a/src/inc/apiv2/model/HealthCheckAPI.php +++ b/src/inc/apiv2/model/HealthCheckAPI.php @@ -33,6 +33,7 @@ public static function getToOneRelationships(): array { 'relationType' => CrackerBinary::class, 'relationKey' => CrackerBinary::CRACKER_BINARY_ID, + 'filterACL' => true, ], 'hashType' => [ 'key' => HealthCheck::HASHTYPE_ID, diff --git a/src/inc/apiv2/model/TaskAPI.php b/src/inc/apiv2/model/TaskAPI.php index 6d8319446..65a956a98 100644 --- a/src/inc/apiv2/model/TaskAPI.php +++ b/src/inc/apiv2/model/TaskAPI.php @@ -82,6 +82,7 @@ public static function getToOneRelationships(): array { 'relationType' => CrackerBinary::class, 'relationKey' => CrackerBinary::CRACKER_BINARY_ID, + 'filterACL' => true, ], 'crackerBinaryType' => [ 'key' => Task::CRACKER_BINARY_TYPE_ID, @@ -330,4 +331,3 @@ protected function getUpdateHandlers($id, $current_user): array { ]; } } - diff --git a/src/inc/apiv2/model/UserAPI.php b/src/inc/apiv2/model/UserAPI.php index 6aa86496e..3cdc4e66a 100644 --- a/src/inc/apiv2/model/UserAPI.php +++ b/src/inc/apiv2/model/UserAPI.php @@ -57,7 +57,7 @@ public static function getToManyRelationships(): array { ]; } - protected static function fetchExpandObjects(array $objects, string $expand): array { + protected static function fetchExpandObjects(array $objects, string $expand, array $relationFilters = []): array { array_walk($objects, function ($obj) { assert($obj instanceof User); }); @@ -133,4 +133,3 @@ protected function getUpdateHandlers($id, $current_user): array { } } - From 6d513d4b960fb2729a69f7bba647553e1bfe4ee6 Mon Sep 17 00:00:00 2001 From: s3inlc Date: Wed, 23 Sep 2026 15:03:33 +0200 Subject: [PATCH 6/6] added check on CrackerBinaryType deletion --- ci/apiv2/test_permissions.py | 50 +++++++++++++++++++- src/inc/apiv2/model/CrackerBinaryTypeAPI.php | 21 ++++++++ 2 files changed, 70 insertions(+), 1 deletion(-) diff --git a/ci/apiv2/test_permissions.py b/ci/apiv2/test_permissions.py index e054e05a7..bb1c28b6f 100644 --- a/ci/apiv2/test_permissions.py +++ b/ci/apiv2/test_permissions.py @@ -4,7 +4,7 @@ import pytest import requests -from hashtopolis import Agent, Chunk, File, Hash, Hashlist, HashType, HealthCheckAgent, Helper, Task, TaskWrapper, User +from hashtopolis import Agent, Chunk, Cracker, CrackerType, File, Hash, Hashlist, HashType, HealthCheckAgent, Helper, Task, TaskWrapper, User from utils import BaseTest, create_apitoken_raw, create_restricted_user, do_create_agentassignent, do_create_dummy_agent, request_with_api_token, get_hashtopolis_uri @@ -1451,6 +1451,54 @@ def test_api_token_health_check_include_enforces_cracker_access_group(self): included = {(item['type'], item['id']) for item in body.get('included', [])} self.assertNotIn(('crackerBinary', cracker.id), included) + def test_api_token_cracker_type_delete_rejects_inaccessible_binary(self): + """Type deletion is atomic when any child binary is outside the user's groups.""" + inaccessible_group = self._create_unique_accessgroup() + cracker_type = self.create_crackertype() + admin_token = self.create_apitoken(extra_payload={'scopes': _all_scopes_except(self, [])}) + admin_membership_response = request_with_api_token( + admin_token.token, + f'/ui/accessgroups/{inaccessible_group.id}/relationships/userMembers', + method='POST', + payload=self._relationship_payload('user', 1), + ) + self.assertEqual(admin_membership_response.status_code, 201, admin_membership_response.text) + + accessible_cracker = self.create_cracker(extra_payload={ + 'crackerBinaryTypeId': cracker_type.id, + 'accessGroupId': 1, + }) + inaccessible_cracker = self.create_cracker(extra_payload={ + 'crackerBinaryTypeId': cracker_type.id, + 'accessGroupId': inaccessible_group.id, + }) + permissions = { + 'permJwtApiKeyCreate': True, + 'permCrackerBinaryTypeDelete': True, + } + auth = create_restricted_user(self, permissions) + user = User.objects.get(name=auth[0]) + + membership_response = request_with_api_token( + admin_token.token, + '/ui/accessgroups/1/relationships/userMembers', + method='POST', + payload=self._relationship_payload('user', user.id), + ) + self.assertEqual(membership_response.status_code, 201, membership_response.text) + + token = create_apitoken_raw(self, auth, ['permCrackerBinaryTypeDelete']) + response = request_with_api_token( + token.token, + f'/ui/crackertypes/{cracker_type.id}', + method='DELETE', + ) + self.assertEqual(response.status_code, 403, response.text) + + self.assertEqual(CrackerType.objects.get(pk=cracker_type.id).id, cracker_type.id) + self.assertEqual(Cracker.objects.get(pk=accessible_cracker.id).id, accessible_cracker.id) + self.assertEqual(Cracker.objects.get(pk=inaccessible_cracker.id).id, inaccessible_cracker.id) + def test_api_token_high_value_helpers_report_each_missing_required_scope(self): """High-value helper endpoints enforce every declared required permission. diff --git a/src/inc/apiv2/model/CrackerBinaryTypeAPI.php b/src/inc/apiv2/model/CrackerBinaryTypeAPI.php index 1dd1e79c4..5dc6a87fc 100644 --- a/src/inc/apiv2/model/CrackerBinaryTypeAPI.php +++ b/src/inc/apiv2/model/CrackerBinaryTypeAPI.php @@ -2,7 +2,11 @@ namespace Hashtopolis\inc\apiv2\model; +use Exception; use Hashtopolis\dba\AbstractModel; +use Hashtopolis\dba\Factory; +use Hashtopolis\dba\QueryFilter; +use Hashtopolis\inc\utils\AccessUtils; use Hashtopolis\inc\utils\CrackerUtils; use Hashtopolis\dba\models\CrackerBinary; @@ -11,7 +15,9 @@ use Hashtopolis\inc\apiv2\common\AbstractModelAPI; use Hashtopolis\inc\apiv2\error\HttpConflict; use Hashtopolis\inc\apiv2\error\HttpError; +use Hashtopolis\inc\apiv2\error\HttpForbidden; use Hashtopolis\inc\HTException; +use Hashtopolis\inc\Util; /** @@ -69,8 +75,23 @@ protected function createObject(array $data): int { /** * @param CrackerBinaryType $object * @throws HTException + * @throws HttpForbidden + * @throws Exception */ protected function deleteObject(AbstractModel $object): void { + $currentUser = $this->getCurrentUser(); + $rightGroup = Factory::getRightGroupFactory()->get($currentUser->getRightGroupId()); + if ($rightGroup->getPermissions() !== 'ALL') { + $accessGroupIds = Util::arrayOfIds(AccessUtils::getAccessGroupsOfUser($currentUser)); + $filter = new QueryFilter(CrackerBinary::CRACKER_BINARY_TYPE_ID, $object->getId(), '='); + $binaries = Factory::getCrackerBinaryFactory()->filter([Factory::FILTER => $filter]); + foreach ($binaries as $binary) { + if (!in_array($binary->getAccessGroupId(), $accessGroupIds)) { + throw new HttpForbidden("No access to all cracker binaries of this type!", 403); + } + } + } + CrackerUtils::deleteBinaryType($object->getId()); } }