Files
panel/app/Http/Controllers/Api/Client/ApiKeyController.php
Noah Ross a94b8bdb4e Fix API Key Limit Race Condition Bypass (#5620)
The API key creation endpoint checks that a user has fewer than 25 keys
before creating a new one. The problem is that the count was read from
an eager-loaded collection (`$user->apiKeys->count()`) with no lock
held, so concurrent requests could both pass the check and each create a
key, pushing the user past the 25-key cap.

The fix wraps the count check and key creation in a single database
transaction with `lockForUpdate()` on the query. Only one request at a
time can evaluate and modify the count, closing the race window.

### Proof of Concept

Run this in the browser console while authenticated with a user that has
24 API keys:

```js
(async () => {
    const makeKey = (desc) => fetch('/api/client/account/api-keys', {
      method: 'POST',
      headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
        'X-Requested-With': 'XMLHttpRequest',
        'X-XSRF-TOKEN':
  decodeURIComponent(document.cookie.match(/XSRF-TOKEN=([^;]+)/)[1]),
      },
      body: JSON.stringify({ description: desc, allowed_ips: [] }),
    });

    const [r1, r2] = await Promise.all([makeKey('0024'), makeKey('0025')]);
    console.log('0024:', r1.status, (await r1.text()).slice(0, 200));
    console.log('0025:', r2.status, (await r2.text()).slice(0, 200));
})();
```

On the old code, both requests can return 200 (you may need to run this
a few times to hit the race window). After the fix, the second request
correctly returns a 400 error.
2026-05-23 11:16:18 -07:00

75 lines
2.3 KiB
PHP

<?php
namespace Pterodactyl\Http\Controllers\Api\Client;
use Pterodactyl\Models\ApiKey;
use Illuminate\Http\JsonResponse;
use Pterodactyl\Facades\Activity;
use Illuminate\Support\Facades\DB;
use Pterodactyl\Exceptions\DisplayException;
use Pterodactyl\Http\Requests\Api\Client\ClientApiRequest;
use Pterodactyl\Transformers\Api\Client\ApiKeyTransformer;
use Pterodactyl\Http\Requests\Api\Client\Account\StoreApiKeyRequest;
class ApiKeyController extends ClientApiController
{
/**
* Returns all the API keys that exist for the given client.
*/
public function index(ClientApiRequest $request): array
{
return $this->fractal->collection($request->user()->apiKeys)
->transformWith($this->getTransformer(ApiKeyTransformer::class))
->toArray();
}
/**
* Store a new API key for a user's account.
*
* @throws DisplayException
*/
public function store(StoreApiKeyRequest $request): array
{
$token = DB::transaction(function () use ($request) {
if ($request->user()->apiKeys()->lockForUpdate()->count() >= 25) {
throw new DisplayException('You have reached the account limit for number of API keys.');
}
return $request->user()->createToken(
$request->input('description'),
$request->input('allowed_ips')
);
});
Activity::event('user:api-key.create')
->subject($token->accessToken)
->property('identifier', $token->accessToken->identifier)
->log();
return $this->fractal->item($token->accessToken)
->transformWith($this->getTransformer(ApiKeyTransformer::class))
->addMeta(['secret_token' => $token->plainTextToken])
->toArray();
}
/**
* Deletes a given API key.
*/
public function delete(ClientApiRequest $request, string $identifier): JsonResponse
{
/** @var ApiKey $key */
$key = $request->user()->apiKeys()
->where('key_type', ApiKey::TYPE_ACCOUNT)
->where('identifier', $identifier)
->firstOrFail();
Activity::event('user:api-key.delete')
->property('identifier', $key->identifier)
->log();
$key->delete();
return new JsonResponse([], JsonResponse::HTTP_NO_CONTENT);
}
}