Skip to content

Commit bf53792

Browse files
committed
Fix username validation and auto-generation, closes pterodactyl#927
1 parent c3dc376 commit bf53792

File tree

7 files changed

+159
-41
lines changed

7 files changed

+159
-41
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ This project follows [Semantic Versioning](http://semver.org) guidelines.
99
* `[rc.2]` — Fixes Admin CP user editing resetting a password on users unintentionally.
1010
* `[rc.2]` — Fixes bug with server creation API endpoint that would fail to validate `allocation.default` correctly.
1111
* `[rc.2]` — Fix data integrity exception occuring due to invalid data being passed to server creation service on the API.
12+
* `[rc.2]` — Fix data integrity exception that could occur when an email containing non-username characters was passed.
1213

1314
### Added
1415
* Added ability to search the following API endpoints: list users, list servers, and list locations.

app/Http/Controllers/API/Remote/SftpController.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,12 @@ public function __construct(AuthenticateUsingPasswordService $authenticationServ
4242
*/
4343
public function index(SftpAuthenticationFormRequest $request): JsonResponse
4444
{
45-
$connection = explode('.', $request->input('username'));
45+
$parts = explode('.', strrev($request->input('username')), 2);
46+
$connection = [
47+
'username' => strrev(array_get($parts, 1)),
48+
'server' => strrev(array_get($parts, 0)),
49+
];
50+
4651
$this->incrementLoginAttempts($request);
4752

4853
if ($this->hasTooManyLoginAttempts($request)) {
@@ -53,10 +58,10 @@ public function index(SftpAuthenticationFormRequest $request): JsonResponse
5358

5459
try {
5560
$data = $this->authenticationService->handle(
56-
array_get($connection, 0),
61+
$connection['username'],
5762
$request->input('password'),
5863
object_get($request->attributes->get('node'), 'id', 0),
59-
array_get($connection, 1)
64+
empty($connection['server']) ? null : $connection['server']
6065
);
6166

6267
$this->clearLoginAttempts($request);

app/Models/User.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use Sofa\Eloquence\Eloquence;
66
use Sofa\Eloquence\Validable;
7+
use Pterodactyl\Rules\Username;
78
use Illuminate\Validation\Rules\In;
89
use Illuminate\Auth\Authenticatable;
910
use Illuminate\Database\Eloquent\Model;
@@ -151,7 +152,7 @@ class User extends Model implements
151152
'uuid' => 'string|size:36|unique:users,uuid',
152153
'email' => 'email|unique:users,email',
153154
'external_id' => 'nullable|string|max:255|unique:users,external_id',
154-
'username' => 'alpha_dash|between:1,255|unique:users,username',
155+
'username' => 'between:1,255|unique:users,username',
155156
'name_first' => 'string|between:1,255',
156157
'name_last' => 'string|between:1,255',
157158
'password' => 'nullable|string',
@@ -169,6 +170,7 @@ protected static function gatherRules()
169170
{
170171
$rules = self::eloquenceGatherRules();
171172
$rules['language'][] = new In(array_keys((new self)->getAvailableLanguages()));
173+
$rules['username'][] = new Username;
172174

173175
return $rules;
174176
}
@@ -188,9 +190,9 @@ public function sendPasswordResetNotification($token)
188190
*
189191
* @param string $value
190192
*/
191-
public function setUsernameAttribute($value)
193+
public function setUsernameAttribute(string $value)
192194
{
193-
$this->attributes['username'] = strtolower($value);
195+
$this->attributes['username'] = mb_strtolower($value);
194196
}
195197

196198
/**

app/Rules/Username.php

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
<?php
2+
3+
namespace Pterodactyl\Rules;
4+
5+
use Illuminate\Contracts\Validation\Rule;
6+
7+
class Username implements Rule
8+
{
9+
public const VALIDATION_REGEX = '/^[a-z0-9]([\w\.-]+)[a-z0-9]$/';
10+
11+
/**
12+
* Validate that a username contains only the allowed characters and starts/ends
13+
* with alpha-numeric characters.
14+
*
15+
* Allowed characters: a-z0-9_-.
16+
*
17+
* @param string $attribute
18+
* @param mixed $value
19+
* @return bool
20+
*/
21+
public function passes($attribute, $value): bool
22+
{
23+
return preg_match(self::VALIDATION_REGEX, mb_strtolower($value));
24+
}
25+
26+
/**
27+
* Return a validation message for use when this rule fails.
28+
*
29+
* @return string
30+
*/
31+
public function message(): string
32+
{
33+
return 'The :attribute must start and end with alpha-numeric characters and
34+
contain only letters, numbers, dashes, underscores, and periods.';
35+
}
36+
}

app/Services/Subusers/SubuserCreationService.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
namespace Pterodactyl\Services\Subusers;
1111

1212
use Pterodactyl\Models\Server;
13+
use Pterodactyl\Rules\Username;
1314
use Illuminate\Database\ConnectionInterface;
1415
use Pterodactyl\Services\Users\UserCreationService;
1516
use Pterodactyl\Contracts\Repository\UserRepositoryInterface;
@@ -117,9 +118,10 @@ public function handle($server, $email, array $permissions)
117118
throw new ServerSubuserExistsException(trans('exceptions.subusers.subuser_exists'));
118119
}
119120
} catch (RecordNotFoundException $exception) {
121+
$username = preg_replace('/([^\w\.-]+)/', '', strtok($email, '@'));
120122
$user = $this->userCreationService->handle([
121123
'email' => $email,
122-
'username' => substr(strtok($email, '@'), 0, 8) . '_' . str_random(6),
124+
'username' => $username . str_random(3),
123125
'name_first' => 'Server',
124126
'name_last' => 'Subuser',
125127
'root_admin' => false,

tests/Unit/Rules/UsernameTest.php

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<?php
2+
3+
namespace Tests\Unit\Rules;
4+
5+
use Tests\TestCase;
6+
use Pterodactyl\Rules\Username;
7+
8+
class UsernameTest extends TestCase
9+
{
10+
/**
11+
* Test valid usernames.
12+
*
13+
* @dataProvider validUsernameDataProvider
14+
*/
15+
public function testValidUsernames(string $username)
16+
{
17+
$this->assertTrue((new Username)->passes('test', $username), 'Assert username is valid.');
18+
}
19+
20+
/**
21+
* Test invalid usernames return false.
22+
*
23+
* @dataProvider invalidUsernameDataProvider
24+
*/
25+
public function testInvalidUsernames(string $username)
26+
{
27+
$this->assertFalse((new Username)->passes('test', $username), 'Assert username is not valid.');
28+
}
29+
30+
/**
31+
* Provide valid usernames.
32+
* @return array
33+
*/
34+
public function validUsernameDataProvider(): array
35+
{
36+
return [
37+
['username'],
38+
['user_name'],
39+
['user.name'],
40+
['user-name'],
41+
['123username123'],
42+
['123-user.name'],
43+
['123456'],
44+
];
45+
}
46+
47+
/**
48+
* Provide invalid usernames.
49+
*
50+
* @return array
51+
*/
52+
public function invalidUsernameDataProvider(): array
53+
{
54+
return [
55+
['_username'],
56+
['username_'],
57+
['_username_'],
58+
['-username'],
59+
['.username'],
60+
['username-'],
61+
['username.'],
62+
['user*name'],
63+
['user^name'],
64+
['user#name'],
65+
['user+name'],
66+
['1234_'],
67+
];
68+
}
69+
}

tests/Unit/Services/Subusers/SubuserCreationServiceTest.php

Lines changed: 37 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,9 @@
11
<?php
2-
/**
3-
* Pterodactyl - Panel
4-
* Copyright (c) 2015 - 2017 Dane Everitt <dane@daneeveritt.com>.
5-
*
6-
* This software is licensed under the terms of the MIT license.
7-
* https://opensource.org/licenses/MIT
8-
*/
92

103
namespace Tests\Unit\Services\Subusers;
114

125
use Mockery as m;
136
use Tests\TestCase;
14-
use phpmock\phpunit\PHPMock;
157
use Pterodactyl\Models\User;
168
use Pterodactyl\Models\Server;
179
use Pterodactyl\Models\Subuser;
@@ -30,8 +22,6 @@
3022

3123
class SubuserCreationServiceTest extends TestCase
3224
{
33-
use PHPMock;
34-
3525
/**
3626
* @var \Illuminate\Database\ConnectionInterface|\Mockery\Mock
3727
*/
@@ -79,25 +69,13 @@ public function setUp()
7969
{
8070
parent::setUp();
8171

82-
$this->getFunctionMock('\\Pterodactyl\\Services\\Subusers', 'str_random')->expects($this->any())->willReturn('random_string');
83-
8472
$this->connection = m::mock(ConnectionInterface::class);
8573
$this->keyCreationService = m::mock(DaemonKeyCreationService::class);
8674
$this->permissionService = m::mock(PermissionCreationService::class);
8775
$this->subuserRepository = m::mock(SubuserRepositoryInterface::class);
8876
$this->serverRepository = m::mock(ServerRepositoryInterface::class);
8977
$this->userCreationService = m::mock(UserCreationService::class);
9078
$this->userRepository = m::mock(UserRepositoryInterface::class);
91-
92-
$this->service = new SubuserCreationService(
93-
$this->connection,
94-
$this->keyCreationService,
95-
$this->permissionService,
96-
$this->serverRepository,
97-
$this->subuserRepository,
98-
$this->userCreationService,
99-
$this->userRepository
100-
);
10179
}
10280

10381
/**
@@ -107,26 +85,33 @@ public function testAccountIsCreatedForNewUser()
10785
{
10886
$permissions = ['test-1' => 'test:1', 'test-2' => null];
10987
$server = factory(Server::class)->make();
110-
$user = factory(User::class)->make();
88+
$user = factory(User::class)->make([
89+
'email' => 'known.1+test@example.com',
90+
]);
11191
$subuser = factory(Subuser::class)->make(['user_id' => $user->id, 'server_id' => $server->id]);
11292

11393
$this->connection->shouldReceive('beginTransaction')->withNoArgs()->once()->andReturnNull();
11494
$this->userRepository->shouldReceive('findFirstWhere')->with([['email', '=', $user->email]])->once()->andThrow(new RecordNotFoundException);
115-
$this->userCreationService->shouldReceive('handle')->with([
116-
'email' => $user->email,
117-
'username' => substr(strtok($user->email, '@'), 0, 8) . '_' . 'random_string',
118-
'name_first' => 'Server',
119-
'name_last' => 'Subuser',
120-
'root_admin' => false,
121-
])->once()->andReturn($user);
95+
$this->userCreationService->shouldReceive('handle')->with(m::on(function ($data) use ($user) {
96+
$subset = m::subset([
97+
'email' => $user->email,
98+
'name_first' => 'Server',
99+
'name_last' => 'Subuser',
100+
'root_admin' => false,
101+
])->match($data);
102+
103+
$username = substr(array_get($data, 'username', ''), 0, -3) === 'known.1test';
104+
105+
return $subset && $username;
106+
}))->once()->andReturn($user);
122107

123108
$this->subuserRepository->shouldReceive('create')->with(['user_id' => $user->id, 'server_id' => $server->id])
124109
->once()->andReturn($subuser);
125110
$this->keyCreationService->shouldReceive('handle')->with($server->id, $user->id)->once()->andReturnNull();
126111
$this->permissionService->shouldReceive('handle')->with($subuser->id, array_keys($permissions))->once()->andReturnNull();
127112
$this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull();
128113

129-
$response = $this->service->handle($server, $user->email, array_keys($permissions));
114+
$response = $this->getService()->handle($server, $user->email, array_keys($permissions));
130115
$this->assertInstanceOf(Subuser::class, $response);
131116
$this->assertSame($subuser, $response);
132117
}
@@ -155,7 +140,7 @@ public function testExistingUserCanBeAddedAsASubuser()
155140
$this->permissionService->shouldReceive('handle')->with($subuser->id, $permissions)->once()->andReturnNull();
156141
$this->connection->shouldReceive('commit')->withNoArgs()->once()->andReturnNull();
157142

158-
$response = $this->service->handle($server->id, $user->email, $permissions);
143+
$response = $this->getService()->handle($server->id, $user->email, $permissions);
159144
$this->assertInstanceOf(Subuser::class, $response);
160145
$this->assertSame($subuser, $response);
161146
}
@@ -172,7 +157,7 @@ public function testExceptionIsThrownIfUserIsServerOwner()
172157
$this->userRepository->shouldReceive('findFirstWhere')->with([['email', '=', $user->email]])->once()->andReturn($user);
173158

174159
try {
175-
$this->service->handle($server, $user->email, []);
160+
$this->getService()->handle($server, $user->email, []);
176161
} catch (DisplayException $exception) {
177162
$this->assertInstanceOf(UserIsServerOwnerException::class, $exception);
178163
$this->assertEquals(trans('exceptions.subusers.user_is_owner'), $exception->getMessage());
@@ -195,10 +180,28 @@ public function testExceptionIsThrownIfUserIsAlreadyASubuser()
195180
])->once()->andReturn(1);
196181

197182
try {
198-
$this->service->handle($server, $user->email, []);
183+
$this->getService()->handle($server, $user->email, []);
199184
} catch (DisplayException $exception) {
200185
$this->assertInstanceOf(ServerSubuserExistsException::class, $exception);
201186
$this->assertEquals(trans('exceptions.subusers.subuser_exists'), $exception->getMessage());
202187
}
203188
}
189+
190+
/**
191+
* Return an instance of the service with mocked dependencies.
192+
*
193+
* @return \Pterodactyl\Services\Subusers\SubuserCreationService
194+
*/
195+
private function getService(): SubuserCreationService
196+
{
197+
return new SubuserCreationService(
198+
$this->connection,
199+
$this->keyCreationService,
200+
$this->permissionService,
201+
$this->serverRepository,
202+
$this->subuserRepository,
203+
$this->userCreationService,
204+
$this->userRepository
205+
);
206+
}
204207
}

0 commit comments

Comments
 (0)