Pterodactyl's improper resource locking allows raced queries to create more resources than alloted
Description
Pterodactyl is a free, open-source game server management panel. Pterodactyl implements rate limits that are applied to the total number of resources (e.g. databases, port allocations, or backups) that can exist for an individual server. These resource limits are applied on a per-server basis, and validated during the request cycle. However, in versions prior to 1.12.0, it is possible for a malicious user to send a massive volume of requests at the same time that would create more resources than the server is allotted. This is because the validation occurs early in the request cycle and does not lock the target resource while it is processing. As a result sending a large volume of requests at the same time would lead all of those requests to validate as not using any of the target resources, and then all creating the resources at the same time. As a result a server would be able to create more databases, allocations, or backups than configured. A malicious user is able to deny resources to other users on the system, and may be able to excessively consume the limited allocations for a node, or fill up backup space faster than is allowed by the system. Version 1.12.0 fixes the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
pterodactyl/panelPackagist | < 1.12.0 | 1.12.0 |
Affected products
1- Range: v0.1.0-beta, v0.1.1-beta, v0.1.2-beta, …
Patches
109caa0d4995bMerge commit from fork
9 files changed · +128 −32
app/Enum/ResourceLimit.php+65 −0 added@@ -0,0 +1,65 @@ +<?php + +namespace Pterodactyl\Enum; + +use Illuminate\Http\Request; +use Webmozart\Assert\Assert; +use Pterodactyl\Models\Server; +use Illuminate\Cache\RateLimiting\Limit; +use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Routing\Middleware\ThrottleRequests; + +/** + * A basic resource throttler for individual servers. This is applied in addition + * to existing rate limits and allows the code to slow down speedy users that might + * be creating resources a little too quickly for comfort. This throttle generally + * only applies to creation flows, and not general view/edit/delete flows. + */ +enum ResourceLimit +{ + case Allocation; + case Backup; + case Database; + case Schedule; + case Subuser; + case Websocket; + case FilePull; + + public function throttleKey(): string + { + return mb_strtolower("api.client:server-resource:{$this->name}"); + } + + /** + * Returns a middleware that will throttle the specific resource by server. This + * throttle applies to any user making changes to that resource on the specific + * server, it is NOT per-user. + */ + public function middleware(): string + { + return ThrottleRequests::using($this->throttleKey()); + } + + public function limit(): Limit + { + return match($this) { + self::Backup => Limit::perMinutes(15, 3), + self::Database => Limit::perMinute(2), + self::FilePull => Limit::perMinutes(10, 5), + self::Subuser => Limit::perMinutes(15, 10), + self::Websocket => Limit::perMinute(5), + default => Limit::perMinute(2), + }; + } + + public static function boot(): void + { + foreach (self::cases() as $case) { + RateLimiter::for($case->throttleKey(), function (Request $request) use ($case) { + Assert::isInstanceOf($server = $request->route()->parameter('server'), Server::class); + + return $case->limit()->by($server->uuid); + }); + } + } +}
app/Http/Controllers/Api/Client/Servers/BackupController.php+12 −6 modified@@ -74,15 +74,21 @@ public function store(StoreBackupRequest $request, Server $server): array // how best to allow a user to create a backup that is locked without also preventing // them from just filling up a server with backups that can never be deleted? if ($request->user()->can(Permission::ACTION_BACKUP_DELETE, $server)) { - $action->setIsLocked((bool) $request->input('is_locked')); + $action->setIsLocked($request->boolean('is_locked')); } - $backup = $action->handle($server, $request->input('name')); + $backup = Activity::event('server:backup.start')->transaction(function ($log) use ($action, $server, $request) { + $server->backups()->lockForUpdate(); - Activity::event('server:backup.start') - ->subject($backup) - ->property(['name' => $backup->name, 'locked' => (bool) $request->input('is_locked')]) - ->log(); + $backup = $action->handle($server, $request->input('name')); + + $log->subject($backup)->property([ + 'name' => $backup->name, + 'locked' => $request->boolean('is_locked'), + ]); + + return $backup; + }); return $this->fractal->item($backup) ->transformWith($this->getTransformer(BackupTransformer::class))
app/Http/Controllers/Api/Client/Servers/DatabaseController.php+14 −10 modified@@ -48,12 +48,15 @@ public function index(GetDatabasesRequest $request, Server $server): array */ public function store(StoreDatabaseRequest $request, Server $server): array { - $database = $this->deployDatabaseService->handle($server, $request->validated()); + $database = Activity::event('server:database.create')->transaction(function ($log) use ($request, $server) { + $server->databases()->lockForUpdate(); - Activity::event('server:database.create') - ->subject($database) - ->property('name', $database->database) - ->log(); + $database = $this->deployDatabaseService->handle($server, $request->validated()); + + $log->subject($database)->property('name', $database->database); + + return $database; + }); return $this->fractal->item($database) ->parseIncludes(['password']) @@ -69,15 +72,16 @@ public function store(StoreDatabaseRequest $request, Server $server): array */ public function rotatePassword(RotatePasswordRequest $request, Server $server, Database $database): array { - $this->passwordService->handle($database); - $database->refresh(); - Activity::event('server:database.rotate-password') ->subject($database) ->property('name', $database->database) - ->log(); + ->transaction(function () use ($database) { + $database->lockForUpdate(); - return $this->fractal->item($database) + $this->passwordService->handle($database); + }); + + return $this->fractal->item($database->refresh()) ->parseIncludes(['password']) ->transformWith($this->getTransformer(DatabaseTransformer::class)) ->toArray();
app/Http/Controllers/Api/Client/Servers/NetworkAllocationController.php+11 −8 modified@@ -6,6 +6,7 @@ use Illuminate\Http\JsonResponse; use Pterodactyl\Facades\Activity; use Pterodactyl\Models\Allocation; +use Illuminate\Database\ConnectionInterface; use Pterodactyl\Exceptions\DisplayException; use Pterodactyl\Repositories\Eloquent\ServerRepository; use Pterodactyl\Transformers\Api\Client\AllocationTransformer; @@ -23,6 +24,7 @@ class NetworkAllocationController extends ClientApiController * NetworkAllocationController constructor. */ public function __construct( + protected readonly ConnectionInterface $connection, private FindAssignableAllocationService $assignableAllocationService, private ServerRepository $serverRepository, ) { @@ -92,16 +94,17 @@ public function setPrimary(SetPrimaryAllocationRequest $request, Server $server, */ public function store(NewAllocationRequest $request, Server $server): array { - if ($server->allocations()->count() >= $server->allocation_limit) { - throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.'); - } + $allocation = Activity::event('server:allocation.create')->transaction(function ($log) use ($server) { + if ($server->allocations()->lockForUpdate()->count() >= $server->allocation_limit) { + throw new DisplayException('Cannot assign additional allocations to this server: limit has been reached.'); + } - $allocation = $this->assignableAllocationService->handle($server); + $allocation = $this->assignableAllocationService->handle($server); - Activity::event('server:allocation.create') - ->subject($allocation) - ->property('allocation', $allocation->toString()) - ->log(); + $log->subject($allocation)->property('allocation', $allocation->toString()); + + return $allocation; + }); return $this->fractal->item($allocation) ->transformWith($this->getTransformer(AllocationTransformer::class))
app/Http/Kernel.php+4 −0 modified@@ -48,6 +48,10 @@ class Kernel extends HttpKernel ConvertEmptyStringsToNull::class, ]; + protected $middlewarePriority = [ + SubstituteClientBindings::class, + ]; + /** * The application's route middleware groups. */
app/Providers/RouteServiceProvider.php+3 −0 modified@@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Pterodactyl\Models\Database; +use Pterodactyl\Enum\ResourceLimit; use Illuminate\Support\Facades\Route; use Illuminate\Cache\RateLimiting\Limit; use Illuminate\Support\Facades\RateLimiter; @@ -106,5 +107,7 @@ protected function configureRateLimiting(): void config('http.rate_limit.application') )->by($key); }); + + ResourceLimit::boot(); } }
app/Services/Activity/ActivityLogService.php+2 −0 modified@@ -166,6 +166,8 @@ public function clone(): self * and will only save the activity log entry if everything else successfully * settles. * + * @param \Closure($this): mixed $callback + * * @throws \Throwable */ public function transaction(\Closure $callback)
config/http.php+1 −1 modified@@ -13,7 +13,7 @@ */ 'rate_limit' => [ 'client_period' => 1, - 'client' => env('APP_API_CLIENT_RATELIMIT', 720), + 'client' => env('APP_API_CLIENT_RATELIMIT', 128), 'application_period' => 1, 'application' => env('APP_API_APPLICATION_RATELIMIT', 240),
routes/api-client.php+16 −7 modified@@ -1,5 +1,6 @@ <?php +use Pterodactyl\Enum\ResourceLimit; use Illuminate\Support\Facades\Route; use Pterodactyl\Http\Controllers\Api\Client; use Pterodactyl\Http\Middleware\Activity\ServerSubject; @@ -60,7 +61,9 @@ ], ], function () { Route::get('/', [Client\Servers\ServerController::class, 'index'])->name('api:client:server.view'); - Route::get('/websocket', Client\Servers\WebsocketController::class)->name('api:client:server.ws'); + Route::middleware([ResourceLimit::Websocket->middleware()]) + ->get('/websocket', Client\Servers\WebsocketController::class) + ->name('api:client:server.ws'); Route::get('/resources', Client\Servers\ResourceUtilizationController::class)->name('api:client:server.resources'); Route::get('/activity', Client\Servers\ActivityLogController::class)->name('api:client:server.activity'); @@ -69,7 +72,8 @@ Route::group(['prefix' => '/databases'], function () { Route::get('/', [Client\Servers\DatabaseController::class, 'index']); - Route::post('/', [Client\Servers\DatabaseController::class, 'store']); + Route::middleware([ResourceLimit::Database->middleware()]) + ->post('/', [Client\Servers\DatabaseController::class, 'store']); Route::post('/{database}/rotate-password', [Client\Servers\DatabaseController::class, 'rotatePassword']); Route::delete('/{database}', [Client\Servers\DatabaseController::class, 'delete']); }); @@ -86,13 +90,15 @@ Route::post('/delete', [Client\Servers\FileController::class, 'delete']); Route::post('/create-folder', [Client\Servers\FileController::class, 'create']); Route::post('/chmod', [Client\Servers\FileController::class, 'chmod']); - Route::post('/pull', [Client\Servers\FileController::class, 'pull'])->middleware(['throttle:10,5']); + Route::middleware([ResourceLimit::FilePull->middleware()]) + ->post('/pull', [Client\Servers\FileController::class, 'pull']); Route::get('/upload', Client\Servers\FileUploadController::class); }); Route::group(['prefix' => '/schedules'], function () { Route::get('/', [Client\Servers\ScheduleController::class, 'index']); - Route::post('/', [Client\Servers\ScheduleController::class, 'store']); + Route::middleware([ResourceLimit::Schedule->middleware()]) + ->post('/', [Client\Servers\ScheduleController::class, 'store']); Route::get('/{schedule}', [Client\Servers\ScheduleController::class, 'view']); Route::post('/{schedule}', [Client\Servers\ScheduleController::class, 'update']); Route::post('/{schedule}/execute', [Client\Servers\ScheduleController::class, 'execute']); @@ -105,15 +111,17 @@ Route::group(['prefix' => '/network'], function () { Route::get('/allocations', [Client\Servers\NetworkAllocationController::class, 'index']); - Route::post('/allocations', [Client\Servers\NetworkAllocationController::class, 'store']); + Route::middleware([ResourceLimit::Allocation->middleware()]) + ->post('/allocations', [Client\Servers\NetworkAllocationController::class, 'store']); Route::post('/allocations/{allocation}', [Client\Servers\NetworkAllocationController::class, 'update']); Route::post('/allocations/{allocation}/primary', [Client\Servers\NetworkAllocationController::class, 'setPrimary']); Route::delete('/allocations/{allocation}', [Client\Servers\NetworkAllocationController::class, 'delete']); }); Route::group(['prefix' => '/users'], function () { Route::get('/', [Client\Servers\SubuserController::class, 'index']); - Route::post('/', [Client\Servers\SubuserController::class, 'store']); + Route::middleware([ResourceLimit::Subuser->middleware()]) + ->post('/', [Client\Servers\SubuserController::class, 'store']); Route::get('/{user}', [Client\Servers\SubuserController::class, 'view']); Route::post('/{user}', [Client\Servers\SubuserController::class, 'update']); Route::delete('/{user}', [Client\Servers\SubuserController::class, 'delete']); @@ -125,7 +133,8 @@ Route::get('/{backup}', [Client\Servers\BackupController::class, 'view']); Route::get('/{backup}/download', [Client\Servers\BackupController::class, 'download']); Route::post('/{backup}/lock', [Client\Servers\BackupController::class, 'toggleLock']); - Route::post('/{backup}/restore', [Client\Servers\BackupController::class, 'restore']); + Route::middleware([ResourceLimit::Backup->middleware()]) + ->post('/{backup}/restore', [Client\Servers\BackupController::class, 'restore']); Route::delete('/{backup}', [Client\Servers\BackupController::class, 'delete']); });
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-jw2v-cq5x-q68gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-69198ghsaADVISORY
- github.com/pterodactyl/panel/commit/09caa0d4995bd924b53b9a9e9b4883ac27bd5607ghsax_refsource_MISCWEB
- github.com/pterodactyl/panel/security/advisories/GHSA-jw2v-cq5x-q68gghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.