CVE-2021-41273 (published 2021-11-17T20:15Z)

Pterodactyl is an open-source game server management panel built with PHP 7, React, and Go. Due to improperly configured CSRF protections on two routes, a malicious user could execute a CSRF-based attack against the following endpoints: Sending a test email and Generating a node auto-deployment token. At no point would any data be exposed to the malicious user, this would simply trigger email spam to an administrative user, or generate a single auto-deployment token unexpectedly. This token is not revealed to the malicious user, it is simply created unexpectedly in the system. This has been addressed in release `1.6.6`. Users may optionally manually apply the fixes released in v1.6.6 to patch their own systems.

Severity: MEDIUM
CVSS v2 base score: 4.3 (AV:N/AC:M/Au:N/C:N/I:P/A:N)
CVSS v3 base score: 4.3, MEDIUM (CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:L/A:N)

Weakness information:
cwe_id: CWE-352
cwe_name: Cross-Site Request Forgery (CSRF)
description: The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
extended_description: When a web server is designed to receive a request from a client without any mechanism for verifying that it was intentionally sent, then it might be possible for an attacker to trick a client into making an unintentional request to the web server which will be treated as an authentic request. This can be done via a URL, image load, XMLHttpRequest, etc. and can result in exposure of data or unintended code execution.
         

Fix information:
repo_url: https://github.com/pterodactyl/panel
commit_details: repo_url: https://github.com/pterodactyl/panel
author: Dane Everitt
author_date: 2021-11-16 20:02:18-08:00
msg: Add consistent CSRF token verification to API endpoints; address security concern with non-CSRF protected endpoints
repository: repo_url: https://github.com/pterodactyl/panel
repo_name: pterodactyl/panel
description: Pterodactyl® is a free, open-source game server management panel built with PHP, React, and Go. Designed with security in mind, Pterodactyl runs all game servers in isolated Docker containers while exposing a beautiful and intuitive UI to end users.
repo_language: PHP
owner: pterodactyl
file_changes: filename: Kernel.php
change_type: ModificationType.MODIFY
diff: @@ -75,6 +75,7 @@ class Kernel extends HttpKernel
             ApiSubstituteBindings::class,
             'api..key:' . ApiKey::TYPE_APPLICATION,
             AuthenticateApplicationUser::class,
+            VerifyCsrfToken::class,
             AuthenticateIPAccess::class,
         ],
         'client-api' => [
@@ -85,6 +86,7 @@ class Kernel extends HttpKernel
             SubstituteClientApiBindings::class,
             'api..key:' . ApiKey::TYPE_ACCOUNT,
             AuthenticateIPAccess::class,
+            VerifyCsrfToken::class,
             // This is perhaps a little backwards with the Client API, but logically you'd be unable
             // to create/get an API key without first enabling 2FA on the account, so I suppose in the
             // end it makes sense.

programming_language: PHP
filename: AuthenticateKey.php
change_type: ModificationType.MODIFY
diff: @@ -8,6 +8,7 @@ use Illuminate\Http\Request;
 use Pterodactyl\Models\User;
 use Pterodactyl\Models\ApiKey;
 use Illuminate\Auth\AuthManager;
+use Illuminate\Support\Facades\Session;
 use Illuminate\Contracts\Encryption\Encrypter;
 use Symfony\Component\HttpKernel\Exception\HttpException;
 use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
@@ -55,7 +56,7 @@ class AuthenticateKey
     public function handle(Request $request, Closure $next, int $keyType)
     {
         if (is_null($request->bearerToken()) && is_null($request->user())) {
-            throw new HttpException(401, null, null, ['WWW-Authenticate' => 'Bearer']);
+            throw new HttpException(401, 'A bearer token or valid user session cookie must be provided to access this endpoint.', null, ['WWW-Authenticate' => 'Bearer']);
         }
 
         // This is a request coming through using cookies, we have an authenticated user

programming_language: PHP
filename: VerifyCsrfToken.php
change_type: ModificationType.MODIFY
diff: @@ -2,18 +2,45 @@
 
 namespace Pterodactyl\Http\Middleware;
 
+use Closure;
+use Pterodactyl\Models\ApiKey;
 use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as BaseVerifier;
 
 class VerifyCsrfToken extends BaseVerifier
 {
     /**
-     * The URIs that should be excluded from CSRF verification.
+     * The URIs that should be excluded from CSRF verification. These are
+     * never hit by the front-end, and require specific token validation
+     * to work.
      *
-     * @var array
+     * @var string[]
      */
-    protected $except = [
-        'remote/*',
-        'daemon/*',
-        'api/*',
-    ];
+    protected $except = ['remote/*', 'daemon/*'];
+
+    /**
+     * Manually apply CSRF protection to routes depending on the authentication
+     * mechanism being used. If the API request is using an API key that exists
+     * in the database we can safely ignore CSRF protections, since that would be
+     * a manually initiated request by a user or server.
+     *
+     * All other requests should go through the standard CSRF protections that
+     * Laravel affords us. This code will be removed in v2 since we have switched
+     * to using Sanctum for the API endpoints, which handles that for us automatically.
+     *
+     * @param \Illuminate\Http\Request $request
+     * @param \Closure $next
+     * @return mixed
+     *
+     * @throws \Illuminate\Session\TokenMismatchException
+     */
+    public function handle($request, Closure $next)
+    {
+        $key = $request->attributes->get('api_key');
+
+        if ($key instanceof ApiKey && $key->exists) {
+            return $next($request);
+        }
+
+        return parent::handle($request, $next);
+    }
 }

programming_language: PHP
filename: http.ts
change_type: ModificationType.MODIFY
diff: @@ -7,10 +7,21 @@ const http: AxiosInstance = axios.create({
         'X-Requested-With': 'XMLHttpRequest',
         Accept: 'application/json',
         'Content-Type': 'application/json',
-        'X-CSRF-Token': (window as any).X_CSRF_TOKEN as string || '',
     },
 });
 
+http.interceptors.request.use(req => {
+    const cookies = document.cookie.split(';').reduce((obj, val) => {
+        const [ key, value ] = val.trim().split('=').map(decodeURIComponent);
+
+        return { ...obj, [key]: value };
+    }, {} as Record<string, string>);
+
+    req.headers['X-XSRF-TOKEN'] = cookies['XSRF-TOKEN'] || 'nil';
+
+    return req;
+});
+
 http.interceptors.request.use(req => {
     if (!req.url?.endsWith('/resources') && (req.url?.indexOf('_debugbar') || -1) < 0) {
         store.getActions().progress.startContinuous();

programming_language: TypeScript
filename: configuration.blade.php
change_type: ModificationType.MODIFY
diff: @@ -70,7 +70,11 @@
     @parent
     <script>
     $('#configTokenBtn').on('click', function (event) {
-        $.getJSON('{{ route('admin.nodes.view.configuration.token', $node->id) }}').done(function (data) {
+        $.ajax({
+            method: 'POST',
+            url: '{{ route('admin.nodes.view.configuration.token', $node->id) }}',
+            headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
+        }).done(function (data) {
             swal({
                 type: 'success',
                 title: 'Token created.',

programming_language: PHP
filename: mail.blade.php
change_type: ModificationType.MODIFY
diff: @@ -145,9 +145,9 @@
                 showLoaderOnConfirm: true
             }, function () {
                 $.ajax({
-                    method: 'GET',
+                    method: 'POST',
                     url: '/admin/settings/mail/test',
-                    headers: { 'X-CSRF-Token': $('input[name="_token"]').val() }
+                    headers: { 'X-CSRF-TOKEN': $('input[name="_token"]').val() }
                 }).fail(function (jqXHR) {
                     showErrorDialog(jqXHR, 'test');
                 }).done(function () {

programming_language: PHP
filename: admin.php
change_type: ModificationType.MODIFY
diff: @@ -66,8 +66,8 @@ Route::group(['prefix' => 'databases'], function () {
 Route::group(['prefix' => 'settings'], function () {
     Route::get('/', 'Settings\IndexController@index')->name('admin.settings');
     Route::get('/mail', 'Settings\MailController@index')->name('admin.settings.mail');
-    Route::get('/mail/test', 'Settings\MailController@test')->name('admin.settings.mail.test');
     Route::get('/advanced', 'Settings\AdvancedController@index')->name('admin.settings.advanced');
+    Route::post('/mail/test', 'Settings\MailController@test')->name('admin.settings.mail.test');
 
     Route::patch('/', 'Settings\IndexController@update');
     Route::patch('/mail', 'Settings\MailController@update');
@@ -153,12 +153,12 @@ Route::group(['prefix' => 'nodes'], function () {
     Route::get('/view/{node}/allocation', 'Nodes\NodeViewController@allocations')->name('admin.nodes.view.allocation');
     Route::get('/view/{node}/servers', 'Nodes\NodeViewController@servers')->name('admin.nodes.view.servers');
     Route::get('/view/{node}/system-information', 'Nodes\SystemInformationController');
-    Route::get('/view/{node}/settings/token', 'NodeAutoDeployController')->name('admin.nodes.view.configuration.token');
 
     Route::post('/new', 'NodesController@store');
     Route::post('/view/{node}/allocation', 'NodesController@createAllocation');
     Route::post('/view/{node}/allocation/remove', 'NodesController@allocationRemoveBlock')->name('admin.nodes.view.allocation.removeBlock');
     Route::post('/view/{node}/allocation/alias', 'NodesController@allocationSetAlias')->name('admin.nodes.view.allocation.setAlias');
+    Route::post('/view/{node}/settings/token', 'NodeAutoDeployController')->name('admin.nodes.view.configuration.token');
 
     Route::patch('/view/{node}/settings', 'NodesController@updateSettings');
 

programming_language: PHP