diff --git a/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php b/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php index 7d4261507..8a6874906 100644 --- a/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php +++ b/app/Http/Controllers/Api/Remote/SftpAuthenticationController.php @@ -62,7 +62,7 @@ class SftpAuthenticationController extends Controller } if (!$key || !$user->sshKeys()->where('fingerprint', $key->getFingerprint('sha256'))->exists()) { - $this->reject($request, false); + $this->reject($request, is_null($key)); } } @@ -70,7 +70,7 @@ class SftpAuthenticationController extends Controller return new JsonResponse([ 'server' => $server->uuid, - 'permissions' => $permissions ?? ['*'], + 'permissions' => $this->permissions->handle($server, $user), ]); } diff --git a/tests/Integration/Api/Client/ClientApiIntegrationTestCase.php b/tests/Integration/Api/Client/ClientApiIntegrationTestCase.php index 35e8d2719..03b80eba4 100644 --- a/tests/Integration/Api/Client/ClientApiIntegrationTestCase.php +++ b/tests/Integration/Api/Client/ClientApiIntegrationTestCase.php @@ -9,7 +9,6 @@ use Pterodactyl\Models\User; use InvalidArgumentException; use Pterodactyl\Models\Backup; use Pterodactyl\Models\Server; -use Pterodactyl\Models\Subuser; use Pterodactyl\Models\Database; use Pterodactyl\Models\Location; use Pterodactyl\Models\Schedule; @@ -88,35 +87,6 @@ abstract class ClientApiIntegrationTestCase extends IntegrationTestCase return $link . ($append ? '/' . ltrim($append, '/') : ''); } - /** - * Generates a user and a server for that user. If an array of permissions is passed it - * is assumed that the user is actually a subuser of the server. - * - * @param string[] $permissions - * - * @return array{\Pterodactyl\Models\User, \Pterodactyl\Models\Server} - */ - protected function generateTestAccount(array $permissions = []): array - { - /** @var \Pterodactyl\Models\User $user */ - $user = User::factory()->create(); - - if (empty($permissions)) { - return [$user, $this->createServerModel(['user_id' => $user->id])]; - } - - /** @var \Pterodactyl\Models\Server $server */ - $server = $this->createServerModel(); - - Subuser::query()->create([ - 'user_id' => $user->id, - 'server_id' => $server->id, - 'permissions' => $permissions, - ]); - - return [$user, $server]; - } - /** * Asserts that the data passed through matches the output of the data from the transformer. This * will remove the "relationships" key when performing the comparison. diff --git a/tests/Integration/Api/Remote/SftpAuthenticationControllerTest.php b/tests/Integration/Api/Remote/SftpAuthenticationControllerTest.php new file mode 100644 index 000000000..4ef485db3 --- /dev/null +++ b/tests/Integration/Api/Remote/SftpAuthenticationControllerTest.php @@ -0,0 +1,250 @@ +generateTestAccount(); + + $user->update(['password' => password_hash('foobar', PASSWORD_DEFAULT)]); + + $this->user = $user; + $this->server = $server; + + $this->setAuthorization(); + } + + /** + * Test that a public key is validated correctly. + */ + public function testPublicKeyIsValidatedCorrectly() + { + $key = UserSSHKey::factory()->for($this->user)->create(); + + $this->postJson('/api/remote/sftp/auth', []) + ->assertUnprocessable() + ->assertJsonPath('errors.0.meta.source_field', 'username') + ->assertJsonPath('errors.0.meta.rule', 'required') + ->assertJsonPath('errors.1.meta.source_field', 'password') + ->assertJsonPath('errors.1.meta.rule', 'required'); + + $data = [ + 'type' => 'public_key', + 'username' => $this->getUsername(), + 'password' => $key->public_key, + ]; + + $this->postJson('/api/remote/sftp/auth', $data) + ->assertOk() + ->assertJsonPath('server', $this->server->uuid) + ->assertJsonPath('permissions', ['*']); + + $key->delete(); + $this->postJson('/api/remote/sftp/auth', $data)->assertForbidden(); + $this->postJson('/api/remote/sftp/auth', array_merge($data, ['type' => null]))->assertForbidden(); + } + + /** + * Test that an account password is validated correctly. + */ + public function testPasswordIsValidatedCorrectly() + { + $this->postJson('/api/remote/sftp/auth', [ + 'username' => $this->getUsername(), + 'password' => '', + ]) + ->assertUnprocessable() + ->assertJsonPath('errors.0.meta.source_field', 'password') + ->assertJsonPath('errors.0.meta.rule', 'required'); + + $this->postJson('/api/remote/sftp/auth', [ + 'username' => $this->getUsername(), + 'password' => 'wrong password', + ]) + ->assertForbidden(); + + $this->user->update(['password' => password_hash('foobar', PASSWORD_DEFAULT)]); + + $this->postJson('/api/remote/sftp/auth', [ + 'username' => $this->getUsername(), + 'password' => 'foobar', + ]) + ->assertOk(); + } + + /** + * Test that providing an invalid key and/or invalid username triggers the throttle on + * the endpoint. + * + * @dataProvider authorizationTypeDataProvider + */ + public function testUserIsThrottledIfInvalidCredentialsAreProvided(string $type) + { + for ($i = 0; $i <= 10; ++$i) { + $this->postJson('/api/remote/sftp/auth', [ + 'type' => 'public_key', + 'username' => $i % 2 === 0 ? $this->user->username : $this->getUsername(), + 'password' => 'invalid key', + ]) + ->assertStatus($i === 10 ? 429 : 403); + } + } + + /** + * Test that the user is not throttled so long as a valid public key is provided, even + * if it doesn't actually exist in the database for the user. + */ + public function testUserIsNotThrottledIfNoPublicKeyMatches() + { + for ($i = 0; $i <= 10; ++$i) { + $this->postJson('/api/remote/sftp/auth', [ + 'type' => 'public_key', + 'username' => $this->getUsername(), + 'password' => PrivateKey::createKey('Ed25519')->getPublicKey()->toString('OpenSSH'), + ]) + ->assertForbidden(); + } + } + + /** + * Test that a request is rejected if the credentials are valid but the username indicates + * a server on a different node. + * + * @dataProvider authorizationTypeDataProvider + */ + public function testRequestIsRejectedIfServerBelongsToDifferentNode(string $type) + { + $node2 = $this->createServerModel()->node; + + $this->setAuthorization($node2); + + $password = $type === 'public_key' + ? UserSSHKey::factory()->for($this->user)->create()->public_key + : 'foobar'; + + $this->postJson('/api/remote/sftp/auth', [ + 'type' => 'public_key', + 'username' => $this->getUsername(), + 'password' => $password, + ]) + ->assertForbidden(); + } + + public function testRequestIsDeniedIfUserLacksSftpPermission() + { + [$user, $server] = $this->generateTestAccount([Permission::ACTION_FILE_READ]); + + $user->update(['password' => password_hash('foobar', PASSWORD_DEFAULT)]); + + $this->setAuthorization($server->node); + + $this->postJson('/api/remote/sftp/auth', [ + 'username' => $user->username . '.' . $server->uuidShort, + 'password' => 'foobar', + ]) + ->assertForbidden() + ->assertJsonPath('errors.0.detail', 'You do not have permission to access SFTP for this server.'); + } + + /** + * @dataProvider serverStateDataProvider + */ + public function testInvalidServerStateReturnsConflictError(string $status) + { + $this->server->update(['status' => $status]); + + $this->postJson('/api/remote/sftp/auth', ['username' => $this->getUsername(), 'password' => 'foobar']) + ->assertStatus(409); + } + + /** + * Test that permissions are returned for the user account correctly. + */ + public function testUserPermissionsAreReturnedCorrectly() + { + [$user, $server] = $this->generateTestAccount([Permission::ACTION_FILE_READ, Permission::ACTION_FILE_SFTP]); + + $user->update(['password' => password_hash('foobar', PASSWORD_DEFAULT)]); + + $this->setAuthorization($server->node); + + $data = [ + 'username' => $user->username . '.' . $server->uuidShort, + 'password' => 'foobar', + ]; + + $this->postJson('/api/remote/sftp/auth', $data) + ->assertOk() + ->assertJsonPath('permissions', [Permission::ACTION_FILE_READ, Permission::ACTION_FILE_SFTP]); + + $user->update(['root_admin' => true]); + + $this->postJson('/api/remote/sftp/auth', $data) + ->assertOk() + ->assertJsonPath('permissions.0', '*'); + + $this->setAuthorization(); + $data['username'] = $user->username . '.' . $this->server->uuidShort; + + $this->post('/api/remote/sftp/auth', $data) + ->assertOk() + ->assertJsonPath('permissions.0', '*'); + + $user->update(['root_admin' => false]); + $this->post('/api/remote/sftp/auth', $data)->assertForbidden(); + } + + public function authorizationTypeDataProvider(): array + { + return [ + 'password auth' => ['password'], + 'public key auth' => ['public_key'], + ]; + } + + public function serverStateDataProvider(): array + { + return [ + 'installing' => [Server::STATUS_INSTALLING], + 'suspended' => [Server::STATUS_SUSPENDED], + 'restoring a backup' => [Server::STATUS_RESTORING_BACKUP], + ]; + } + + /** + * Returns the username for connecting to SFTP. + */ + protected function getUsername(bool $long = false): string + { + return $this->user->username . '.' . ($long ? $this->server->uuid : $this->server->uuidShort); + } + + /** + * Sets the authorization header for the rest of the test. + */ + protected function setAuthorization(Node $node = null): void + { + $node = $node ?? $this->server->node; + + $this->withHeader('Authorization', 'Bearer ' . $node->daemon_token_id . '.' . decrypt($node->daemon_token)); + } +} diff --git a/tests/Traits/Integration/CreatesTestModels.php b/tests/Traits/Integration/CreatesTestModels.php index 0885ccc5a..51e4b76aa 100644 --- a/tests/Traits/Integration/CreatesTestModels.php +++ b/tests/Traits/Integration/CreatesTestModels.php @@ -7,6 +7,7 @@ use Pterodactyl\Models\Egg; use Pterodactyl\Models\Node; use Pterodactyl\Models\User; use Pterodactyl\Models\Server; +use Pterodactyl\Models\Subuser; use Pterodactyl\Models\Location; use Pterodactyl\Models\Allocation; @@ -76,6 +77,35 @@ trait CreatesTestModels ]); } + /** + * Generates a user and a server for that user. If an array of permissions is passed it + * is assumed that the user is actually a subuser of the server. + * + * @param string[] $permissions + * + * @return array{\Pterodactyl\Models\User, \Pterodactyl\Models\Server} + */ + public function generateTestAccount(array $permissions = []): array + { + /** @var \Pterodactyl\Models\User $user */ + $user = User::factory()->create(); + + if (empty($permissions)) { + return [$user, $this->createServerModel(['user_id' => $user->id])]; + } + + /** @var \Pterodactyl\Models\Server $server */ + $server = $this->createServerModel(); + + Subuser::query()->create([ + 'user_id' => $user->id, + 'server_id' => $server->id, + 'permissions' => $permissions, + ]); + + return [$user, $server]; + } + /** * Clones a given egg allowing us to make modifications that don't affect other * tests that rely on the egg existing in the correct state.