Merge branch 'develop' into ts3-query-fix
This commit is contained in:
commit
ef1989f38b
27 changed files with 593 additions and 96 deletions
|
@ -3,6 +3,9 @@ This file is a running track of new features and fixes to each version of the pa
|
||||||
|
|
||||||
This project follows [Semantic Versioning](http://semver.org) guidelines.
|
This project follows [Semantic Versioning](http://semver.org) guidelines.
|
||||||
|
|
||||||
|
## v1.2.2
|
||||||
|
* **[security]** Fixes authentication bypass allowing a user to take control of specific server actions such as executing schedules, rotating database passwords, and viewing or deleting a backup.
|
||||||
|
|
||||||
## v1.2.1
|
## v1.2.1
|
||||||
### Fixed
|
### Fixed
|
||||||
* Fixes URL-encoding of filenames when working in the filemanager to fix issues when moving, renaming, or deleting files.
|
* Fixes URL-encoding of filenames when working in the filemanager to fix issues when moving, renaming, or deleting files.
|
||||||
|
|
|
@ -42,13 +42,14 @@ class Utilities
|
||||||
* @param string $minute
|
* @param string $minute
|
||||||
* @param string $hour
|
* @param string $hour
|
||||||
* @param string $dayOfMonth
|
* @param string $dayOfMonth
|
||||||
|
* @param string $month
|
||||||
* @param string $dayOfWeek
|
* @param string $dayOfWeek
|
||||||
* @return \Carbon\Carbon
|
* @return \Carbon\Carbon
|
||||||
*/
|
*/
|
||||||
public static function getScheduleNextRunDate(string $minute, string $hour, string $dayOfMonth, string $dayOfWeek)
|
public static function getScheduleNextRunDate(string $minute, string $hour, string $dayOfMonth, string $month, string $dayOfWeek)
|
||||||
{
|
{
|
||||||
return Carbon::instance(CronExpression::factory(
|
return Carbon::instance(CronExpression::factory(
|
||||||
sprintf('%s %s %s * %s', $minute, $hour, $dayOfMonth, $dayOfWeek)
|
sprintf('%s %s %s %s %s', $minute, $hour, $dayOfMonth, $month, $dayOfWeek)
|
||||||
)->getNextRunDate());
|
)->getNextRunDate());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -84,6 +84,7 @@ class ScheduleController extends ClientApiController
|
||||||
'server_id' => $server->id,
|
'server_id' => $server->id,
|
||||||
'name' => $request->input('name'),
|
'name' => $request->input('name'),
|
||||||
'cron_day_of_week' => $request->input('day_of_week'),
|
'cron_day_of_week' => $request->input('day_of_week'),
|
||||||
|
'cron_month' => $request->input('month'),
|
||||||
'cron_day_of_month' => $request->input('day_of_month'),
|
'cron_day_of_month' => $request->input('day_of_month'),
|
||||||
'cron_hour' => $request->input('hour'),
|
'cron_hour' => $request->input('hour'),
|
||||||
'cron_minute' => $request->input('minute'),
|
'cron_minute' => $request->input('minute'),
|
||||||
|
@ -136,6 +137,7 @@ class ScheduleController extends ClientApiController
|
||||||
$data = [
|
$data = [
|
||||||
'name' => $request->input('name'),
|
'name' => $request->input('name'),
|
||||||
'cron_day_of_week' => $request->input('day_of_week'),
|
'cron_day_of_week' => $request->input('day_of_week'),
|
||||||
|
'cron_month' => $request->input('month'),
|
||||||
'cron_day_of_month' => $request->input('day_of_month'),
|
'cron_day_of_month' => $request->input('day_of_month'),
|
||||||
'cron_hour' => $request->input('hour'),
|
'cron_hour' => $request->input('hour'),
|
||||||
'cron_minute' => $request->input('minute'),
|
'cron_minute' => $request->input('minute'),
|
||||||
|
@ -211,6 +213,7 @@ class ScheduleController extends ClientApiController
|
||||||
$request->input('minute'),
|
$request->input('minute'),
|
||||||
$request->input('hour'),
|
$request->input('hour'),
|
||||||
$request->input('day_of_month'),
|
$request->input('day_of_month'),
|
||||||
|
$request->input('month'),
|
||||||
$request->input('day_of_week')
|
$request->input('day_of_week')
|
||||||
);
|
);
|
||||||
} catch (Exception $exception) {
|
} catch (Exception $exception) {
|
||||||
|
|
|
@ -1,33 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Pterodactyl\Http\Middleware\Api\Client\Server;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
|
||||||
|
|
||||||
class AllocationBelongsToServer
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Ensure that the allocation found in the URL belongs to the server being queried.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
* @param \Closure $next
|
|
||||||
* @return mixed
|
|
||||||
*
|
|
||||||
* @throws \Symfony\Component\HttpKernel\Exception\NotFoundHttpException
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next)
|
|
||||||
{
|
|
||||||
/** @var \Pterodactyl\Models\Server $server */
|
|
||||||
$server = $request->route()->parameter('server');
|
|
||||||
/** @var \Pterodactyl\Models\Allocation|null $allocation */
|
|
||||||
$allocation = $request->route()->parameter('allocation');
|
|
||||||
|
|
||||||
if ($allocation && $allocation->server_id !== $server->id) {
|
|
||||||
throw new NotFoundHttpException;
|
|
||||||
}
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Http\Middleware\Api\Client\Server;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Pterodactyl\Models\Task;
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use Pterodactyl\Models\Server;
|
||||||
|
use Pterodactyl\Models\Backup;
|
||||||
|
use Pterodactyl\Models\Subuser;
|
||||||
|
use Pterodactyl\Models\Schedule;
|
||||||
|
use Pterodactyl\Models\Database;
|
||||||
|
use Pterodactyl\Models\Allocation;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
|
|
||||||
|
class ResourceBelongsToServer
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Looks at the request parameters to determine if the given resource belongs
|
||||||
|
* to the requested server. If not, a 404 error will be returned to the caller.
|
||||||
|
*
|
||||||
|
* This is critical to ensuring that all subsequent logic is using exactly the
|
||||||
|
* server that is expected, and that we're not accessing a resource completely
|
||||||
|
* unrelated to the server provided in the request.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Request $request
|
||||||
|
* @param \Closure $next
|
||||||
|
* @return mixed
|
||||||
|
*/
|
||||||
|
public function handle(Request $request, Closure $next)
|
||||||
|
{
|
||||||
|
$params = $request->route()->parameters();
|
||||||
|
if (is_null($params) || ! $params['server'] instanceof Server) {
|
||||||
|
throw new InvalidArgumentException('This middleware cannot be used in a context that is missing a server in the parameters.');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var \Pterodactyl\Models\Server $server */
|
||||||
|
$server = $request->route()->parameter('server');
|
||||||
|
$exception = new NotFoundHttpException('The requested resource was not found for this server.');
|
||||||
|
foreach ($params as $key => $model) {
|
||||||
|
// Specifically skip the server, we're just trying to see if all of the
|
||||||
|
// other resources are assigned to this server. Also skip anything that
|
||||||
|
// is not currently a Model instance since those will just end up being
|
||||||
|
// a 404 down the road.
|
||||||
|
if ($key === 'server' || ! $model instanceof Model) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (get_class($model)) {
|
||||||
|
// All of these models use "server_id" as the field key for the server
|
||||||
|
// they are assigned to, so the logic is identical for them all.
|
||||||
|
case Allocation::class:
|
||||||
|
case Backup::class:
|
||||||
|
case Database::class:
|
||||||
|
case Schedule::class:
|
||||||
|
case Subuser::class:
|
||||||
|
if ($model->server_id !== $server->id) {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
// Regular users are a special case here as we need to make sure they're
|
||||||
|
// currently assigned as a subuser on the server.
|
||||||
|
case User::class:
|
||||||
|
$subuser = $server->subusers()->where('user_id', $model->id)->first();
|
||||||
|
if (is_null($subuser)) {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
// This is a special case to avoid an additional query being triggered
|
||||||
|
// in the underlying logic.
|
||||||
|
$request->attributes->set('subuser', $subuser);
|
||||||
|
break;
|
||||||
|
// Tasks are special since they're (currently) the only item in the API
|
||||||
|
// that requires something in addition to the server in order to be accessed.
|
||||||
|
case Task::class:
|
||||||
|
$schedule = $request->route()->parameter('schedule');
|
||||||
|
if ($model->schedule_id !== $schedule->id || $schedule->server_id !== $server->id) {
|
||||||
|
throw $exception;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Don't return a 404 here since we want to make sure no one relies
|
||||||
|
// on this middleware in a context in which it will not work. Fail safe.
|
||||||
|
throw new InvalidArgumentException('There is no handler configured for a resource of this type: ' . get_class($model));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $next($request);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,36 +0,0 @@
|
||||||
<?php
|
|
||||||
|
|
||||||
namespace Pterodactyl\Http\Middleware\Api\Client\Server;
|
|
||||||
|
|
||||||
use Closure;
|
|
||||||
use Illuminate\Http\Request;
|
|
||||||
|
|
||||||
class SubuserBelongsToServer
|
|
||||||
{
|
|
||||||
/**
|
|
||||||
* Ensure that the user being accessed in the request is a user that is currently assigned
|
|
||||||
* as a subuser for this server instance. We'll let the requests themselves handle wether or
|
|
||||||
* not the user making the request can actually modify or delete the subuser record.
|
|
||||||
*
|
|
||||||
* @param \Illuminate\Http\Request $request
|
|
||||||
* @param \Closure $next
|
|
||||||
*
|
|
||||||
* @return mixed
|
|
||||||
*/
|
|
||||||
public function handle(Request $request, Closure $next)
|
|
||||||
{
|
|
||||||
/** @var \Pterodactyl\Models\Server $server */
|
|
||||||
$server = $request->route()->parameter('server');
|
|
||||||
/** @var \Pterodactyl\Models\User $user */
|
|
||||||
$user = $request->route()->parameter('user');
|
|
||||||
|
|
||||||
// Don't do anything if there isn't a user present in the request.
|
|
||||||
if (is_null($user)) {
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
|
|
||||||
$request->attributes->set('subuser', $server->subusers()->where('user_id', $user->id)->firstOrFail());
|
|
||||||
|
|
||||||
return $next($request);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -12,6 +12,7 @@ use Pterodactyl\Contracts\Extensions\HashidsInterface;
|
||||||
* @property int $server_id
|
* @property int $server_id
|
||||||
* @property string $name
|
* @property string $name
|
||||||
* @property string $cron_day_of_week
|
* @property string $cron_day_of_week
|
||||||
|
* @property string $cron_month
|
||||||
* @property string $cron_day_of_month
|
* @property string $cron_day_of_month
|
||||||
* @property string $cron_hour
|
* @property string $cron_hour
|
||||||
* @property string $cron_minute
|
* @property string $cron_minute
|
||||||
|
@ -58,6 +59,7 @@ class Schedule extends Model
|
||||||
'server_id',
|
'server_id',
|
||||||
'name',
|
'name',
|
||||||
'cron_day_of_week',
|
'cron_day_of_week',
|
||||||
|
'cron_month',
|
||||||
'cron_day_of_month',
|
'cron_day_of_month',
|
||||||
'cron_hour',
|
'cron_hour',
|
||||||
'cron_minute',
|
'cron_minute',
|
||||||
|
@ -93,6 +95,7 @@ class Schedule extends Model
|
||||||
protected $attributes = [
|
protected $attributes = [
|
||||||
'name' => null,
|
'name' => null,
|
||||||
'cron_day_of_week' => '*',
|
'cron_day_of_week' => '*',
|
||||||
|
'cron_month' => '*',
|
||||||
'cron_day_of_month' => '*',
|
'cron_day_of_month' => '*',
|
||||||
'cron_hour' => '*',
|
'cron_hour' => '*',
|
||||||
'cron_minute' => '*',
|
'cron_minute' => '*',
|
||||||
|
@ -107,6 +110,7 @@ class Schedule extends Model
|
||||||
'server_id' => 'required|exists:servers,id',
|
'server_id' => 'required|exists:servers,id',
|
||||||
'name' => 'required|string|max:191',
|
'name' => 'required|string|max:191',
|
||||||
'cron_day_of_week' => 'required|string',
|
'cron_day_of_week' => 'required|string',
|
||||||
|
'cron_month' => 'required|string',
|
||||||
'cron_day_of_month' => 'required|string',
|
'cron_day_of_month' => 'required|string',
|
||||||
'cron_hour' => 'required|string',
|
'cron_hour' => 'required|string',
|
||||||
'cron_minute' => 'required|string',
|
'cron_minute' => 'required|string',
|
||||||
|
@ -123,7 +127,7 @@ class Schedule extends Model
|
||||||
*/
|
*/
|
||||||
public function getNextRunDate()
|
public function getNextRunDate()
|
||||||
{
|
{
|
||||||
$formatted = sprintf('%s %s %s * %s', $this->cron_minute, $this->cron_hour, $this->cron_day_of_month, $this->cron_day_of_week);
|
$formatted = sprintf('%s %s %s %s %s', $this->cron_minute, $this->cron_hour, $this->cron_day_of_month, $this->cron_month, $this->cron_day_of_week);
|
||||||
|
|
||||||
return CarbonImmutable::createFromTimestamp(
|
return CarbonImmutable::createFromTimestamp(
|
||||||
CronExpression::factory($formatted)->getNextRunDate()->getTimestamp()
|
CronExpression::factory($formatted)->getNextRunDate()->getTimestamp()
|
||||||
|
|
|
@ -40,6 +40,7 @@ class ScheduleTransformer extends BaseClientTransformer
|
||||||
'cron' => [
|
'cron' => [
|
||||||
'day_of_week' => $model->cron_day_of_week,
|
'day_of_week' => $model->cron_day_of_week,
|
||||||
'day_of_month' => $model->cron_day_of_month,
|
'day_of_month' => $model->cron_day_of_month,
|
||||||
|
'month' => $model->cron_month,
|
||||||
'hour' => $model->cron_hour,
|
'hour' => $model->cron_hour,
|
||||||
'minute' => $model->cron_minute,
|
'minute' => $model->cron_minute,
|
||||||
],
|
],
|
||||||
|
|
|
@ -7,6 +7,8 @@ use Illuminate\Support\Str;
|
||||||
use Pterodactyl\Models\Node;
|
use Pterodactyl\Models\Node;
|
||||||
use Faker\Generator as Faker;
|
use Faker\Generator as Faker;
|
||||||
use Pterodactyl\Models\ApiKey;
|
use Pterodactyl\Models\ApiKey;
|
||||||
|
use Pterodactyl\Models\Backup;
|
||||||
|
use Pterodactyl\Models\Permission;
|
||||||
|
|
||||||
/** @var \Illuminate\Database\Eloquent\Factory $factory */
|
/** @var \Illuminate\Database\Eloquent\Factory $factory */
|
||||||
/*
|
/*
|
||||||
|
@ -134,7 +136,9 @@ $factory->state(Pterodactyl\Models\EggVariable::class, 'editable', function () {
|
||||||
});
|
});
|
||||||
|
|
||||||
$factory->define(Pterodactyl\Models\Subuser::class, function (Faker $faker) {
|
$factory->define(Pterodactyl\Models\Subuser::class, function (Faker $faker) {
|
||||||
return [];
|
return [
|
||||||
|
'permissions' => [Permission::ACTION_WEBSOCKET_CONNECT],
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
$factory->define(Pterodactyl\Models\Allocation::class, function (Faker $faker) {
|
$factory->define(Pterodactyl\Models\Allocation::class, function (Faker $faker) {
|
||||||
|
@ -161,7 +165,7 @@ $factory->define(Pterodactyl\Models\Database::class, function (Faker $faker) {
|
||||||
'database' => str_random(10),
|
'database' => str_random(10),
|
||||||
'username' => str_random(10),
|
'username' => str_random(10),
|
||||||
'remote' => '%',
|
'remote' => '%',
|
||||||
'password' => $password ?: bcrypt('test123'),
|
'password' => $password ?: encrypt('test123'),
|
||||||
'created_at' => Carbon::now()->toDateTimeString(),
|
'created_at' => Carbon::now()->toDateTimeString(),
|
||||||
'updated_at' => Carbon::now()->toDateTimeString(),
|
'updated_at' => Carbon::now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
|
@ -196,3 +200,12 @@ $factory->define(Pterodactyl\Models\ApiKey::class, function (Faker $faker) {
|
||||||
'updated_at' => Carbon::now()->toDateTimeString(),
|
'updated_at' => Carbon::now()->toDateTimeString(),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$factory->define(Pterodactyl\Models\Backup::class, function (Faker $faker) {
|
||||||
|
return [
|
||||||
|
'uuid' => Uuid::uuid4()->toString(),
|
||||||
|
'is_successful' => true,
|
||||||
|
'name' => $faker->sentence,
|
||||||
|
'disk' => Backup::ADAPTER_WINGS,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
32
database/migrations/2021_01_13_013420_add_cron_month.php
Normal file
32
database/migrations/2021_01_13_013420_add_cron_month.php
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
class AddCronMonth extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
Schema::table('schedules', function (Blueprint $table) {
|
||||||
|
$table->string('cron_month')->after('cron_day_of_week');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function down()
|
||||||
|
{
|
||||||
|
Schema::table('schedules', function (Blueprint $table) {
|
||||||
|
$table->dropColumn('cron_month');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,6 +11,7 @@ export default (uuid: string, schedule: Data): Promise<Schedule> => {
|
||||||
minute: schedule.cron.minute,
|
minute: schedule.cron.minute,
|
||||||
hour: schedule.cron.hour,
|
hour: schedule.cron.hour,
|
||||||
day_of_month: schedule.cron.dayOfMonth,
|
day_of_month: schedule.cron.dayOfMonth,
|
||||||
|
month: schedule.cron.month,
|
||||||
day_of_week: schedule.cron.dayOfWeek,
|
day_of_week: schedule.cron.dayOfWeek,
|
||||||
})
|
})
|
||||||
.then(({ data }) => resolve(rawDataToServerSchedule(data.attributes)))
|
.then(({ data }) => resolve(rawDataToServerSchedule(data.attributes)))
|
||||||
|
|
|
@ -5,6 +5,7 @@ export interface Schedule {
|
||||||
name: string;
|
name: string;
|
||||||
cron: {
|
cron: {
|
||||||
dayOfWeek: string;
|
dayOfWeek: string;
|
||||||
|
month: string;
|
||||||
dayOfMonth: string;
|
dayOfMonth: string;
|
||||||
hour: string;
|
hour: string;
|
||||||
minute: string;
|
minute: string;
|
||||||
|
@ -46,6 +47,7 @@ export const rawDataToServerSchedule = (data: any): Schedule => ({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
cron: {
|
cron: {
|
||||||
dayOfWeek: data.cron.day_of_week,
|
dayOfWeek: data.cron.day_of_week,
|
||||||
|
month: data.cron.month,
|
||||||
dayOfMonth: data.cron.day_of_month,
|
dayOfMonth: data.cron.day_of_month,
|
||||||
hour: data.cron.hour,
|
hour: data.cron.hour,
|
||||||
minute: data.cron.minute,
|
minute: data.cron.minute,
|
||||||
|
|
|
@ -16,8 +16,9 @@ import Pagination from '@/components/elements/Pagination';
|
||||||
export default () => {
|
export default () => {
|
||||||
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
const { clearFlashes, clearAndAddHttpError } = useFlash();
|
||||||
const [ page, setPage ] = useState(1);
|
const [ page, setPage ] = useState(1);
|
||||||
const { rootAdmin } = useStoreState(state => state.user.data!);
|
const uuid = useStoreState(state => state.user.data!.uuid);
|
||||||
const [ showOnlyAdmin, setShowOnlyAdmin ] = usePersistedState('show_all_servers', false);
|
const rootAdmin = useStoreState(state => state.user.data!.rootAdmin);
|
||||||
|
const [ showOnlyAdmin, setShowOnlyAdmin ] = usePersistedState(`${uuid}:show_all_servers`, false);
|
||||||
|
|
||||||
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
|
const { data: servers, error } = useSWR<PaginatedResult<Server>>(
|
||||||
[ '/api/client/servers', showOnlyAdmin, page ],
|
[ '/api/client/servers', showOnlyAdmin, page ],
|
||||||
|
|
|
@ -19,6 +19,7 @@ type Props = {
|
||||||
interface Values {
|
interface Values {
|
||||||
name: string;
|
name: string;
|
||||||
dayOfWeek: string;
|
dayOfWeek: string;
|
||||||
|
month: string;
|
||||||
dayOfMonth: string;
|
dayOfMonth: string;
|
||||||
hour: string;
|
hour: string;
|
||||||
minute: string;
|
minute: string;
|
||||||
|
@ -38,7 +39,7 @@ const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdate
|
||||||
label={'Schedule name'}
|
label={'Schedule name'}
|
||||||
description={'A human readable identifer for this schedule.'}
|
description={'A human readable identifer for this schedule.'}
|
||||||
/>
|
/>
|
||||||
<div css={tw`grid grid-cols-2 sm:grid-cols-4 gap-4 mt-6`}>
|
<div css={tw`grid grid-cols-2 sm:grid-cols-5 gap-4 mt-6`}>
|
||||||
<div>
|
<div>
|
||||||
<Field name={'minute'} label={'Minute'}/>
|
<Field name={'minute'} label={'Minute'}/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -48,6 +49,9 @@ const EditScheduleModal = ({ schedule, ...props }: Omit<Props, 'onScheduleUpdate
|
||||||
<div>
|
<div>
|
||||||
<Field name={'dayOfMonth'} label={'Day of month'}/>
|
<Field name={'dayOfMonth'} label={'Day of month'}/>
|
||||||
</div>
|
</div>
|
||||||
|
<div>
|
||||||
|
<Field name={'month'} label={'Month'}/>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Field name={'dayOfWeek'} label={'Day of week'}/>
|
<Field name={'dayOfWeek'} label={'Day of week'}/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -94,6 +98,7 @@ export default ({ schedule, visible, ...props }: Props) => {
|
||||||
minute: values.minute,
|
minute: values.minute,
|
||||||
hour: values.hour,
|
hour: values.hour,
|
||||||
dayOfWeek: values.dayOfWeek,
|
dayOfWeek: values.dayOfWeek,
|
||||||
|
month: values.month,
|
||||||
dayOfMonth: values.dayOfMonth,
|
dayOfMonth: values.dayOfMonth,
|
||||||
},
|
},
|
||||||
isActive: values.enabled,
|
isActive: values.enabled,
|
||||||
|
@ -116,10 +121,11 @@ export default ({ schedule, visible, ...props }: Props) => {
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
initialValues={{
|
initialValues={{
|
||||||
name: schedule?.name || '',
|
name: schedule?.name || '',
|
||||||
dayOfWeek: schedule?.cron.dayOfWeek || '*',
|
|
||||||
dayOfMonth: schedule?.cron.dayOfMonth || '*',
|
|
||||||
hour: schedule?.cron.hour || '*',
|
|
||||||
minute: schedule?.cron.minute || '*/5',
|
minute: schedule?.cron.minute || '*/5',
|
||||||
|
hour: schedule?.cron.hour || '*',
|
||||||
|
dayOfMonth: schedule?.cron.dayOfMonth || '*',
|
||||||
|
month: schedule?.cron.month || '*',
|
||||||
|
dayOfWeek: schedule?.cron.dayOfWeek || '*',
|
||||||
enabled: schedule ? schedule.isActive : true,
|
enabled: schedule ? schedule.isActive : true,
|
||||||
} as Values}
|
} as Values}
|
||||||
validationSchema={null}
|
validationSchema={null}
|
||||||
|
|
|
@ -22,7 +22,7 @@ const ScheduleCronRow = ({ cron, className }: Props) => (
|
||||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Month)</p>
|
<p css={tw`text-2xs text-neutral-500 uppercase`}>Day (Month)</p>
|
||||||
</div>
|
</div>
|
||||||
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
||||||
<p css={tw`font-medium`}>*</p>
|
<p css={tw`font-medium`}>{cron.month}</p>
|
||||||
<p css={tw`text-2xs text-neutral-500 uppercase`}>Month</p>
|
<p css={tw`text-2xs text-neutral-500 uppercase`}>Month</p>
|
||||||
</div>
|
</div>
|
||||||
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
<div css={tw`w-1/5 sm:w-auto text-center ml-4`}>
|
||||||
|
|
|
@ -28,9 +28,9 @@ interface State {
|
||||||
}
|
}
|
||||||
|
|
||||||
const CronBox = ({ title, value }: { title: string; value: string }) => (
|
const CronBox = ({ title, value }: { title: string; value: string }) => (
|
||||||
<div css={tw`bg-neutral-700 rounded p-4`}>
|
<div css={tw`bg-neutral-700 rounded p-3`}>
|
||||||
<p css={tw`text-neutral-300 text-sm`}>{title}</p>
|
<p css={tw`text-neutral-300 text-sm`}>{title}</p>
|
||||||
<p css={tw`text-2xl font-medium text-neutral-100`}>{value}</p>
|
<p css={tw`text-xl font-medium text-neutral-100`}>{value}</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -88,13 +88,6 @@ export default () => {
|
||||||
:
|
:
|
||||||
<>
|
<>
|
||||||
<ScheduleCronRow cron={schedule.cron} css={tw`sm:hidden bg-neutral-700 rounded mb-4 p-3`}/>
|
<ScheduleCronRow cron={schedule.cron} css={tw`sm:hidden bg-neutral-700 rounded mb-4 p-3`}/>
|
||||||
<div css={tw`hidden sm:grid grid-cols-5 md:grid-cols-7 gap-4 mb-6`}>
|
|
||||||
<CronBox title={'Minute'} value={schedule.cron.minute}/>
|
|
||||||
<CronBox title={'Hour'} value={schedule.cron.hour}/>
|
|
||||||
<CronBox title={'Day (Month)'} value={schedule.cron.dayOfMonth}/>
|
|
||||||
<CronBox title={'Month'} value={'*'}/>
|
|
||||||
<CronBox title={'Day (Week)'} value={schedule.cron.dayOfWeek}/>
|
|
||||||
</div>
|
|
||||||
<div css={tw`rounded shadow`}>
|
<div css={tw`rounded shadow`}>
|
||||||
<div css={tw`sm:flex items-center bg-neutral-900 p-3 sm:p-6 border-b-4 border-neutral-600 rounded-t`}>
|
<div css={tw`sm:flex items-center bg-neutral-900 p-3 sm:p-6 border-b-4 border-neutral-600 rounded-t`}>
|
||||||
<div css={tw`flex-1`}>
|
<div css={tw`flex-1`}>
|
||||||
|
@ -143,6 +136,13 @@ export default () => {
|
||||||
</Can>
|
</Can>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div css={tw`hidden sm:grid grid-cols-5 md:grid-cols-5 gap-4 mb-4 mt-4`}>
|
||||||
|
<CronBox title={'Minute'} value={schedule.cron.minute}/>
|
||||||
|
<CronBox title={'Hour'} value={schedule.cron.hour}/>
|
||||||
|
<CronBox title={'Day (Month)'} value={schedule.cron.dayOfMonth}/>
|
||||||
|
<CronBox title={'Month'} value={schedule.cron.month}/>
|
||||||
|
<CronBox title={'Day (Week)'} value={schedule.cron.dayOfWeek}/>
|
||||||
|
</div>
|
||||||
<div css={tw`bg-neutral-700 rounded-b`}>
|
<div css={tw`bg-neutral-700 rounded-b`}>
|
||||||
{schedule.tasks.length > 0 ?
|
{schedule.tasks.length > 0 ?
|
||||||
schedule.tasks.map(task => (
|
schedule.tasks.map(task => (
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
import { Dispatch, SetStateAction, useEffect, useState } from 'react';
|
||||||
|
|
||||||
export function usePersistedState<S = undefined> (key: string, defaultValue: S): [S | undefined, Dispatch<SetStateAction<S | undefined>>] {
|
export function usePersistedState<S = undefined> (key: string, defaultValue: S): [ S | undefined, Dispatch<SetStateAction<S | undefined>> ] {
|
||||||
const [ state, setState ] = useState(
|
const [ state, setState ] = useState(
|
||||||
() => {
|
() => {
|
||||||
try {
|
try {
|
||||||
|
@ -12,7 +12,7 @@ export function usePersistedState<S = undefined> (key: string, defaultValue: S):
|
||||||
|
|
||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
@ -3,6 +3,7 @@
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication;
|
use Pterodactyl\Http\Middleware\RequireTwoFactorAuthentication;
|
||||||
use Pterodactyl\Http\Middleware\Api\Client\Server\SubuserBelongsToServer;
|
use Pterodactyl\Http\Middleware\Api\Client\Server\SubuserBelongsToServer;
|
||||||
|
use Pterodactyl\Http\Middleware\Api\Client\Server\ResourceBelongsToServer;
|
||||||
use Pterodactyl\Http\Middleware\Api\Client\Server\AuthenticateServerAccess;
|
use Pterodactyl\Http\Middleware\Api\Client\Server\AuthenticateServerAccess;
|
||||||
use Pterodactyl\Http\Middleware\Api\Client\Server\AllocationBelongsToServer;
|
use Pterodactyl\Http\Middleware\Api\Client\Server\AllocationBelongsToServer;
|
||||||
|
|
||||||
|
@ -39,7 +40,7 @@ Route::group(['prefix' => '/account'], function () {
|
||||||
| Endpoint: /api/client/servers/{server}
|
| Endpoint: /api/client/servers/{server}
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServerAccess::class]], function () {
|
Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServerAccess::class, ResourceBelongsToServer::class]], function () {
|
||||||
Route::get('/', 'Servers\ServerController@index')->name('api:client:server.view');
|
Route::get('/', 'Servers\ServerController@index')->name('api:client:server.view');
|
||||||
Route::get('/websocket', 'Servers\WebsocketController')->name('api:client:server.ws');
|
Route::get('/websocket', 'Servers\WebsocketController')->name('api:client:server.ws');
|
||||||
Route::get('/resources', 'Servers\ResourceUtilizationController')->name('api:client:server.resources');
|
Route::get('/resources', 'Servers\ResourceUtilizationController')->name('api:client:server.resources');
|
||||||
|
@ -83,7 +84,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
|
||||||
Route::delete('/{schedule}/tasks/{task}', 'Servers\ScheduleTaskController@delete');
|
Route::delete('/{schedule}/tasks/{task}', 'Servers\ScheduleTaskController@delete');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::group(['prefix' => '/network', 'middleware' => [AllocationBelongsToServer::class]], function () {
|
Route::group(['prefix' => '/network'], function () {
|
||||||
Route::get('/allocations', 'Servers\NetworkAllocationController@index');
|
Route::get('/allocations', 'Servers\NetworkAllocationController@index');
|
||||||
Route::post('/allocations', 'Servers\NetworkAllocationController@store');
|
Route::post('/allocations', 'Servers\NetworkAllocationController@store');
|
||||||
Route::post('/allocations/{allocation}', 'Servers\NetworkAllocationController@update');
|
Route::post('/allocations/{allocation}', 'Servers\NetworkAllocationController@update');
|
||||||
|
@ -91,7 +92,7 @@ Route::group(['prefix' => '/servers/{server}', 'middleware' => [AuthenticateServ
|
||||||
Route::delete('/allocations/{allocation}', 'Servers\NetworkAllocationController@delete');
|
Route::delete('/allocations/{allocation}', 'Servers\NetworkAllocationController@delete');
|
||||||
});
|
});
|
||||||
|
|
||||||
Route::group(['prefix' => '/users', 'middleware' => [SubuserBelongsToServer::class]], function () {
|
Route::group(['prefix' => '/users'], function () {
|
||||||
Route::get('/', 'Servers\SubuserController@index');
|
Route::get('/', 'Servers\SubuserController@index');
|
||||||
Route::post('/', 'Servers\SubuserController@store');
|
Route::post('/', 'Servers\SubuserController@store');
|
||||||
Route::get('/{user}', 'Servers\SubuserController@view');
|
Route::get('/{user}', 'Servers\SubuserController@view');
|
||||||
|
|
|
@ -10,11 +10,15 @@ use Pterodactyl\Models\Task;
|
||||||
use Pterodactyl\Models\User;
|
use Pterodactyl\Models\User;
|
||||||
use Webmozart\Assert\Assert;
|
use Webmozart\Assert\Assert;
|
||||||
use Pterodactyl\Models\Server;
|
use Pterodactyl\Models\Server;
|
||||||
|
use Pterodactyl\Models\Backup;
|
||||||
use Pterodactyl\Models\Subuser;
|
use Pterodactyl\Models\Subuser;
|
||||||
use Pterodactyl\Models\Location;
|
use Pterodactyl\Models\Location;
|
||||||
use Pterodactyl\Models\Schedule;
|
use Pterodactyl\Models\Schedule;
|
||||||
|
use Pterodactyl\Models\Database;
|
||||||
use Illuminate\Support\Collection;
|
use Illuminate\Support\Collection;
|
||||||
use Pterodactyl\Models\Allocation;
|
use Pterodactyl\Models\Allocation;
|
||||||
|
use Pterodactyl\Models\DatabaseHost;
|
||||||
|
use Pterodactyl\Tests\Integration\TestResponse;
|
||||||
use Pterodactyl\Tests\Integration\IntegrationTestCase;
|
use Pterodactyl\Tests\Integration\IntegrationTestCase;
|
||||||
use Pterodactyl\Transformers\Api\Client\BaseClientTransformer;
|
use Pterodactyl\Transformers\Api\Client\BaseClientTransformer;
|
||||||
|
|
||||||
|
@ -25,6 +29,9 @@ abstract class ClientApiIntegrationTestCase extends IntegrationTestCase
|
||||||
*/
|
*/
|
||||||
protected function tearDown(): void
|
protected function tearDown(): void
|
||||||
{
|
{
|
||||||
|
Database::query()->forceDelete();
|
||||||
|
DatabaseHost::query()->forceDelete();
|
||||||
|
Backup::query()->forceDelete();
|
||||||
Server::query()->forceDelete();
|
Server::query()->forceDelete();
|
||||||
Node::query()->forceDelete();
|
Node::query()->forceDelete();
|
||||||
Location::query()->forceDelete();
|
Location::query()->forceDelete();
|
||||||
|
@ -44,6 +51,19 @@ abstract class ClientApiIntegrationTestCase extends IntegrationTestCase
|
||||||
CarbonImmutable::setTestNow(Carbon::now());
|
CarbonImmutable::setTestNow(Carbon::now());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Override the default createTestResponse from Illuminate so that we can
|
||||||
|
* just dump 500-level errors to the screen in the tests without having
|
||||||
|
* to keep re-assigning variables.
|
||||||
|
*
|
||||||
|
* @param \Illuminate\Http\Response $response
|
||||||
|
* @return \Illuminate\Testing\TestResponse
|
||||||
|
*/
|
||||||
|
protected function createTestResponse($response)
|
||||||
|
{
|
||||||
|
return TestResponse::fromBaseResponse($response);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a link to the specific resource using the client API.
|
* Returns a link to the specific resource using the client API.
|
||||||
*
|
*
|
||||||
|
|
|
@ -0,0 +1,63 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Tests\Integration\Api\Client\Server\Allocation;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\Subuser;
|
||||||
|
use Pterodactyl\Models\Schedule;
|
||||||
|
use Pterodactyl\Models\Allocation;
|
||||||
|
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
||||||
|
|
||||||
|
class AllocationAuthorizationTest extends ClientApiIntegrationTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $method
|
||||||
|
* @param string $endpoint
|
||||||
|
* @dataProvider methodDataProvider
|
||||||
|
*/
|
||||||
|
public function testAccessToAServersAllocationsIsRestrictedProperly(string $method, string $endpoint)
|
||||||
|
{
|
||||||
|
// The API $user is the owner of $server1.
|
||||||
|
[$user, $server1] = $this->generateTestAccount();
|
||||||
|
// Will be a subuser of $server2.
|
||||||
|
$server2 = $this->createServerModel();
|
||||||
|
// And as no access to $server3.
|
||||||
|
$server3 = $this->createServerModel();
|
||||||
|
|
||||||
|
// Set the API $user as a subuser of server 2, but with no permissions
|
||||||
|
// to do anything with the allocations for that server.
|
||||||
|
factory(Subuser::class)->create(['server_id' => $server2->id, 'user_id' => $user->id]);
|
||||||
|
|
||||||
|
$allocation1 = factory(Allocation::class)->create(['server_id' => $server1->id, 'node_id' => $server1->node_id]);
|
||||||
|
$allocation2 = factory(Allocation::class)->create(['server_id' => $server2->id, 'node_id' => $server2->node_id]);
|
||||||
|
$allocation3 = factory(Allocation::class)->create(['server_id' => $server3->id, 'node_id' => $server3->node_id]);
|
||||||
|
|
||||||
|
// This is the only valid call for this test, accessing the allocation for the same
|
||||||
|
// server that the API user is the owner of.
|
||||||
|
$response = $this->actingAs($user)->json($method, $this->link($server1, "/network/allocations/" . $allocation1->id . $endpoint));
|
||||||
|
$this->assertTrue($response->status() <= 204 || $response->status() === 400 || $response->status() === 422);
|
||||||
|
|
||||||
|
// This request fails because the allocation is valid for that server but the user
|
||||||
|
// making the request is not authorized to perform that action.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server2, "/network/allocations/" . $allocation2->id . $endpoint))->assertForbidden();
|
||||||
|
|
||||||
|
// Both of these should report a 404 error due to the allocations being linked to
|
||||||
|
// servers that are not the same as the server in the request, or are assigned
|
||||||
|
// to a server for which the user making the request has no access to.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/network/allocations/" . $allocation2->id . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/network/allocations/" . $allocation3->id . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server2, "/network/allocations/" . $allocation3->id . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server3, "/network/allocations/" . $allocation3->id . $endpoint))->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \string[][]
|
||||||
|
*/
|
||||||
|
public function methodDataProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
["POST", ""],
|
||||||
|
["DELETE", ""],
|
||||||
|
["POST", "/primary"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Tests\Integration\Api\Client\Server\Backup;
|
||||||
|
|
||||||
|
use Mockery;
|
||||||
|
use Carbon\CarbonImmutable;
|
||||||
|
use Pterodactyl\Models\Backup;
|
||||||
|
use Pterodactyl\Models\Subuser;
|
||||||
|
use Pterodactyl\Services\Backups\DeleteBackupService;
|
||||||
|
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
||||||
|
|
||||||
|
class BackupAuthorizationTest extends ClientApiIntegrationTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $method
|
||||||
|
* @param string $endpoint
|
||||||
|
* @dataProvider methodDataProvider
|
||||||
|
*/
|
||||||
|
public function testAccessToAServersBackupIsRestrictedProperly(string $method, string $endpoint)
|
||||||
|
{
|
||||||
|
// The API $user is the owner of $server1.
|
||||||
|
[$user, $server1] = $this->generateTestAccount();
|
||||||
|
// Will be a subuser of $server2.
|
||||||
|
$server2 = $this->createServerModel();
|
||||||
|
// And as no access to $server3.
|
||||||
|
$server3 = $this->createServerModel();
|
||||||
|
|
||||||
|
// Set the API $user as a subuser of server 2, but with no permissions
|
||||||
|
// to do anything with the backups for that server.
|
||||||
|
factory(Subuser::class)->create(['server_id' => $server2->id, 'user_id' => $user->id]);
|
||||||
|
|
||||||
|
$backup1 = factory(Backup::class)->create(['server_id' => $server1->id, 'completed_at' => CarbonImmutable::now()]);
|
||||||
|
$backup2 = factory(Backup::class)->create(['server_id' => $server2->id, 'completed_at' => CarbonImmutable::now()]);
|
||||||
|
$backup3 = factory(Backup::class)->create(['server_id' => $server3->id, 'completed_at' => CarbonImmutable::now()]);
|
||||||
|
|
||||||
|
$this->instance(DeleteBackupService::class, $mock = Mockery::mock(DeleteBackupService::class));
|
||||||
|
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$mock->expects('handle')->andReturnUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is the only valid call for this test, accessing the backup for the same
|
||||||
|
// server that the API user is the owner of.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/backups/" . $backup1->uuid . $endpoint))
|
||||||
|
->assertStatus($method === 'DELETE' ? 204 : 200);
|
||||||
|
|
||||||
|
// This request fails because the backup is valid for that server but the user
|
||||||
|
// making the request is not authorized to perform that action.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server2, "/backups/" . $backup2->uuid . $endpoint))->assertForbidden();
|
||||||
|
|
||||||
|
// Both of these should report a 404 error due to the backup being linked to
|
||||||
|
// servers that are not the same as the server in the request, or are assigned
|
||||||
|
// to a server for which the user making the request has no access to.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/backups/" . $backup2->uuid . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/backups/" . $backup3->uuid . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server2, "/backups/" . $backup3->uuid . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server3, "/backups/" . $backup3->uuid . $endpoint))->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \string[][]
|
||||||
|
*/
|
||||||
|
public function methodDataProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
["GET", ""],
|
||||||
|
["GET", "/download"],
|
||||||
|
["DELETE", ""],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,78 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Tests\Integration\Api\Client\Server\Database;
|
||||||
|
|
||||||
|
use Mockery;
|
||||||
|
use Pterodactyl\Models\Subuser;
|
||||||
|
use Pterodactyl\Models\Database;
|
||||||
|
use Pterodactyl\Models\DatabaseHost;
|
||||||
|
use Pterodactyl\Contracts\Extensions\HashidsInterface;
|
||||||
|
use Pterodactyl\Services\Databases\DatabasePasswordService;
|
||||||
|
use Pterodactyl\Services\Databases\DatabaseManagementService;
|
||||||
|
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
||||||
|
|
||||||
|
class DatabaseAuthorizationTest extends ClientApiIntegrationTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param string $method
|
||||||
|
* @param string $endpoint
|
||||||
|
* @dataProvider methodDataProvider
|
||||||
|
*/
|
||||||
|
public function testAccessToAServersDatabasesIsRestrictedProperly(string $method, string $endpoint)
|
||||||
|
{
|
||||||
|
// The API $user is the owner of $server1.
|
||||||
|
[$user, $server1] = $this->generateTestAccount();
|
||||||
|
// Will be a subuser of $server2.
|
||||||
|
$server2 = $this->createServerModel();
|
||||||
|
// And as no access to $server3.
|
||||||
|
$server3 = $this->createServerModel();
|
||||||
|
|
||||||
|
$host = factory(DatabaseHost::class)->create([]);
|
||||||
|
|
||||||
|
// Set the API $user as a subuser of server 2, but with no permissions
|
||||||
|
// to do anything with the databases for that server.
|
||||||
|
factory(Subuser::class)->create(['server_id' => $server2->id, 'user_id' => $user->id]);
|
||||||
|
|
||||||
|
$database1 = factory(Database::class)->create(['server_id' => $server1->id, 'database_host_id' => $host->id]);
|
||||||
|
$database2 = factory(Database::class)->create(['server_id' => $server2->id, 'database_host_id' => $host->id]);
|
||||||
|
$database3 = factory(Database::class)->create(['server_id' => $server3->id, 'database_host_id' => $host->id]);
|
||||||
|
|
||||||
|
$this->instance(DatabasePasswordService::class, $mock = Mockery::mock(DatabasePasswordService::class));
|
||||||
|
$this->instance(DatabaseManagementService::class, $mock2 = Mockery::mock(DatabaseManagementService::class));
|
||||||
|
|
||||||
|
if ($method === 'POST') {
|
||||||
|
$mock->expects('handle')->andReturnUndefined();
|
||||||
|
} else {
|
||||||
|
$mock2->expects('delete')->andReturnUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
$hashids = $this->app->make(HashidsInterface::class);
|
||||||
|
// This is the only valid call for this test, accessing the database for the same
|
||||||
|
// server that the API user is the owner of.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/databases/" . $hashids->encode($database1->id) . $endpoint))
|
||||||
|
->assertStatus($method === 'DELETE' ? 204 : 200);
|
||||||
|
|
||||||
|
// This request fails because the database is valid for that server but the user
|
||||||
|
// making the request is not authorized to perform that action.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server2, "/databases/" . $hashids->encode($database2->id) . $endpoint))->assertForbidden();
|
||||||
|
|
||||||
|
// Both of these should report a 404 error due to the database being linked to
|
||||||
|
// servers that are not the same as the server in the request, or are assigned
|
||||||
|
// to a server for which the user making the request has no access to.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/databases/" . $hashids->encode($database2->id) . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/databases/" . $hashids->encode($database3->id) . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server2, "/databases/" . $hashids->encode($database3->id) . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server3, "/databases/" . $hashids->encode($database3->id) . $endpoint))->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \string[][]
|
||||||
|
*/
|
||||||
|
public function methodDataProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
["POST", "/rotate-password"],
|
||||||
|
["DELETE", ""],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,6 +25,7 @@ class CreateServerScheduleTest extends ClientApiIntegrationTestCase
|
||||||
'minute' => '0',
|
'minute' => '0',
|
||||||
'hour' => '*/2',
|
'hour' => '*/2',
|
||||||
'day_of_week' => '2',
|
'day_of_week' => '2',
|
||||||
|
'month' => '1',
|
||||||
'day_of_month' => '*',
|
'day_of_month' => '*',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
@ -39,6 +40,7 @@ class CreateServerScheduleTest extends ClientApiIntegrationTestCase
|
||||||
$this->assertSame('0', $schedule->cron_minute);
|
$this->assertSame('0', $schedule->cron_minute);
|
||||||
$this->assertSame('*/2', $schedule->cron_hour);
|
$this->assertSame('*/2', $schedule->cron_hour);
|
||||||
$this->assertSame('2', $schedule->cron_day_of_week);
|
$this->assertSame('2', $schedule->cron_day_of_week);
|
||||||
|
$this->assertSame('1', $schedule->cron_month);
|
||||||
$this->assertSame('*', $schedule->cron_day_of_month);
|
$this->assertSame('*', $schedule->cron_day_of_month);
|
||||||
$this->assertSame('Test Schedule', $schedule->name);
|
$this->assertSame('Test Schedule', $schedule->name);
|
||||||
|
|
||||||
|
@ -69,6 +71,7 @@ class CreateServerScheduleTest extends ClientApiIntegrationTestCase
|
||||||
'minute' => '*',
|
'minute' => '*',
|
||||||
'hour' => '*',
|
'hour' => '*',
|
||||||
'day_of_month' => '*',
|
'day_of_month' => '*',
|
||||||
|
'month' => '*',
|
||||||
'day_of_week' => '*',
|
'day_of_week' => '*',
|
||||||
])
|
])
|
||||||
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
->assertStatus(Response::HTTP_UNPROCESSABLE_ENTITY)
|
||||||
|
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Tests\Integration\Api\Client\Server\Schedule;
|
||||||
|
|
||||||
|
use Pterodactyl\Models\Subuser;
|
||||||
|
use Pterodactyl\Models\Schedule;
|
||||||
|
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
||||||
|
|
||||||
|
class ScheduleAuthorizationTest extends ClientApiIntegrationTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Tests that a subuser with access to two servers cannot improperly access a resource
|
||||||
|
* on Server A when providing a URL that points to Server B. This prevents a regression
|
||||||
|
* in the code where controllers didn't properly validate that a resource was assigned
|
||||||
|
* to the server that was also present in the URL.
|
||||||
|
*
|
||||||
|
* The comments within the test code itself are better at explaining exactly what is
|
||||||
|
* being tested and protected against.
|
||||||
|
*
|
||||||
|
* @param string $method
|
||||||
|
* @param string $endpoint
|
||||||
|
* @dataProvider methodDataProvider
|
||||||
|
*/
|
||||||
|
public function testAccessToAServersSchedulesIsRestrictedProperly(string $method, string $endpoint)
|
||||||
|
{
|
||||||
|
// The API $user is the owner of $server1.
|
||||||
|
[$user, $server1] = $this->generateTestAccount();
|
||||||
|
// Will be a subuser of $server2.
|
||||||
|
$server2 = $this->createServerModel();
|
||||||
|
// And as no access to $server3.
|
||||||
|
$server3 = $this->createServerModel();
|
||||||
|
|
||||||
|
// Set the API $user as a subuser of server 2, but with no permissions
|
||||||
|
// to do anything with the schedules for that server.
|
||||||
|
factory(Subuser::class)->create(['server_id' => $server2->id, 'user_id' => $user->id]);
|
||||||
|
|
||||||
|
$schedule1 = factory(Schedule::class)->create(['server_id' => $server1->id]);
|
||||||
|
$schedule2 = factory(Schedule::class)->create(['server_id' => $server2->id]);
|
||||||
|
$schedule3 = factory(Schedule::class)->create(['server_id' => $server3->id]);
|
||||||
|
|
||||||
|
// This is the only valid call for this test, accessing the schedule for the same
|
||||||
|
// server that the API user is the owner of.
|
||||||
|
$response = $this->actingAs($user)->json($method, $this->link($server1, "/schedules/" . $schedule1->id . $endpoint));
|
||||||
|
$this->assertTrue($response->status() <= 204 || $response->status() === 400 || $response->status() === 422);
|
||||||
|
|
||||||
|
// This request fails because the schedule is valid for that server but the user
|
||||||
|
// making the request is not authorized to perform that action.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server2, "/schedules/" . $schedule2->id . $endpoint))->assertForbidden();
|
||||||
|
|
||||||
|
// Both of these should report a 404 error due to the schedules being linked to
|
||||||
|
// servers that are not the same as the server in the request, or are assigned
|
||||||
|
// to a server for which the user making the request has no access to.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/schedules/" . $schedule2->id . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/schedules/" . $schedule3->id . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server2, "/schedules/" . $schedule3->id . $endpoint))->assertNotFound();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server3, "/schedules/" . $schedule3->id . $endpoint))->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \string[][]
|
||||||
|
*/
|
||||||
|
public function methodDataProvider(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
["GET", ""],
|
||||||
|
["POST", ""],
|
||||||
|
["DELETE", ""],
|
||||||
|
["POST", "/execute"],
|
||||||
|
["POST", "/tasks"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
|
@ -19,6 +19,7 @@ class UpdateServerScheduleTest extends ClientApiIntegrationTestCase
|
||||||
'minute' => '5',
|
'minute' => '5',
|
||||||
'hour' => '*',
|
'hour' => '*',
|
||||||
'day_of_week' => '*',
|
'day_of_week' => '*',
|
||||||
|
'month' => '*',
|
||||||
'day_of_month' => '*',
|
'day_of_month' => '*',
|
||||||
'is_active' => false,
|
'is_active' => false,
|
||||||
];
|
];
|
||||||
|
@ -35,7 +36,7 @@ class UpdateServerScheduleTest extends ClientApiIntegrationTestCase
|
||||||
|
|
||||||
/** @var \Pterodactyl\Models\Schedule $schedule */
|
/** @var \Pterodactyl\Models\Schedule $schedule */
|
||||||
$schedule = factory(Schedule::class)->create(['server_id' => $server->id]);
|
$schedule = factory(Schedule::class)->create(['server_id' => $server->id]);
|
||||||
$expected = Utilities::getScheduleNextRunDate('5', '*', '*', '*');
|
$expected = Utilities::getScheduleNextRunDate('5', '*', '*', '*', '*');
|
||||||
|
|
||||||
$response = $this->actingAs($user)
|
$response = $this->actingAs($user)
|
||||||
->postJson("/api/client/servers/{$server->uuid}/schedules/{$schedule->id}", $this->updateData);
|
->postJson("/api/client/servers/{$server->uuid}/schedules/{$schedule->id}", $this->updateData);
|
||||||
|
|
|
@ -0,0 +1,61 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Tests\Integration\Api\Client\Server\Subuser;
|
||||||
|
|
||||||
|
use Mockery;
|
||||||
|
use Pterodactyl\Models\User;
|
||||||
|
use Pterodactyl\Models\Subuser;
|
||||||
|
use Pterodactyl\Repositories\Wings\DaemonServerRepository;
|
||||||
|
use Pterodactyl\Tests\Integration\Api\Client\ClientApiIntegrationTestCase;
|
||||||
|
|
||||||
|
class SubuserAuthorizationTest extends ClientApiIntegrationTestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Test that mismatched subusers are not accessible to a server.
|
||||||
|
*
|
||||||
|
* @param string $method
|
||||||
|
* @dataProvider methodDataProvider
|
||||||
|
*/
|
||||||
|
public function testUserCannotAccessResourceBelongingToOtherServers(string $method)
|
||||||
|
{
|
||||||
|
// Generic subuser, the specific resource we're trying to access.
|
||||||
|
/** @var \Pterodactyl\Models\User $internal */
|
||||||
|
$internal = factory(User::class)->create();
|
||||||
|
|
||||||
|
// The API $user is the owner of $server1.
|
||||||
|
[$user, $server1] = $this->generateTestAccount();
|
||||||
|
// Will be a subuser of $server2.
|
||||||
|
$server2 = $this->createServerModel();
|
||||||
|
// And as no access to $server3.
|
||||||
|
$server3 = $this->createServerModel();
|
||||||
|
|
||||||
|
// Set the API $user as a subuser of server 2, but with no permissions
|
||||||
|
// to do anything with the subusers for that server.
|
||||||
|
factory(Subuser::class)->create(['server_id' => $server2->id, 'user_id' => $user->id]);
|
||||||
|
|
||||||
|
factory(Subuser::class)->create(['server_id' => $server1->id, 'user_id' => $internal->id]);
|
||||||
|
factory(Subuser::class)->create(['server_id' => $server2->id, 'user_id' => $internal->id]);
|
||||||
|
factory(Subuser::class)->create(['server_id' => $server3->id, 'user_id' => $internal->id]);
|
||||||
|
|
||||||
|
$this->instance(DaemonServerRepository::class, $mock = Mockery::mock(DaemonServerRepository::class));
|
||||||
|
if ($method === 'DELETE') {
|
||||||
|
$mock->expects('setServer->revokeUserJTI')->with($internal->id)->andReturnUndefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
// This route is acceptable since they're accessing a subuser on their own server.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server1, "/users/" . $internal->uuid))->assertStatus($method === 'POST' ? 422 : ($method === 'DELETE' ? 204 : 200));
|
||||||
|
|
||||||
|
// This route can be revealed since the subuser belongs to the correct server, but
|
||||||
|
// errors out with a 403 since $user does not have the right permissions for this.
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server2, "/users/" . $internal->uuid))->assertForbidden();
|
||||||
|
$this->actingAs($user)->json($method, $this->link($server3, "/users/" . $internal->uuid))->assertNotFound();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return \string[][]
|
||||||
|
*/
|
||||||
|
public function methodDataProvider(): array
|
||||||
|
{
|
||||||
|
return [["GET"], ["POST"], ["DELETE"]];
|
||||||
|
}
|
||||||
|
}
|
37
tests/Integration/TestResponse.php
Normal file
37
tests/Integration/TestResponse.php
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Pterodactyl\Tests\Integration;
|
||||||
|
|
||||||
|
use Illuminate\Testing\Assert as PHPUnit;
|
||||||
|
use Pterodactyl\Exceptions\DisplayException;
|
||||||
|
use Illuminate\Validation\ValidationException;
|
||||||
|
use Illuminate\Testing\TestResponse as IlluminateTestResponse;
|
||||||
|
|
||||||
|
class TestResponse extends IlluminateTestResponse
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Overrides the default assert status logic to dump out the error to the
|
||||||
|
* test output if it is caused by a 500 level error and we were not specifically
|
||||||
|
* look for that status response.
|
||||||
|
*
|
||||||
|
* @param int $status
|
||||||
|
* @return \Pterodactyl\Tests\Integration\TestResponse
|
||||||
|
*/
|
||||||
|
public function assertStatus($status)
|
||||||
|
{
|
||||||
|
$actual = $this->getStatusCode();
|
||||||
|
|
||||||
|
// Dump the response to the screen before making the assertion which is going
|
||||||
|
// to fail so that debugging isn't such a nightmare.
|
||||||
|
if ($actual !== $status && $status !== 500) {
|
||||||
|
$this->dump();
|
||||||
|
if (! is_null($this->exception) && ! $this->exception instanceof DisplayException && ! $this->exception instanceof ValidationException) {
|
||||||
|
dump($this->exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PHPUnit::assertSame($actual, $status, "Expected status code {$status} but received {$actual}.");
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue