CVE-2021-41273: Cross-Site Request Forgery (CSRF) (CWE-352)
value: 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.
CWE 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.
CVSS v3 base score: 4.3
Published: 2021-11-17T20:15Z
Repository: https://github.com/pterodactyl/panel
Language: PHP
Files changed: app/Http/Kernel.php, app/Http/Middleware/Api/AuthenticateKey.php, app/Http/Middleware/VerifyCsrfToken.php, resources/scripts/api/http.ts, resources/views/admin/nodes/view/configuration.blade.php, resources/views/admin/settings/mail.blade.php, routes/admin.php
Commit date: 2021-11-16 20:02:18 -0800
Commit message: Add consistent CSRF token verification to API endpoints; address security concern with non-CSRF protected endpoints

Diff: diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php
index 04a663f33..3fac902c1 100644
--- a/app/Http/Kernel.php
+++ b/app/Http/Kernel.php
@@ -73,20 +73,22 @@ class Kernel extends HttpKernel
             StartSession::class,
             AuthenticateSession::class,
             ApiSubstituteBindings::class,
             'api..key:' . ApiKey::TYPE_APPLICATION,
             AuthenticateApplicationUser::class,
+            VerifyCsrfToken::class,
             AuthenticateIPAccess::class,
         ],
         'client-api' => [
             HandleStatelessRequest::class,
             IsValidJson::class,
             StartSession::class,
             AuthenticateSession::class,
             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.
             //
             // You just wouldn't be authenticating with the API by providing a 2FA token.
diff --git a/app/Http/Middleware/Api/AuthenticateKey.php b/app/Http/Middleware/Api/AuthenticateKey.php
index 397ba8c23..eb25dac6f 100644
--- a/app/Http/Middleware/Api/AuthenticateKey.php
+++ b/app/Http/Middleware/Api/AuthenticateKey.php
@@ -6,10 +6,11 @@ use Closure;
 use Carbon\CarbonImmutable;
 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;
 use Pterodactyl\Contracts\Repository\ApiKeyRepositoryInterface;
 use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
@@ -53,11 +54,11 @@ class AuthenticateKey
      * @throws \Pterodactyl\Exceptions\Repository\RecordNotFoundException
      */
     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
         // not using an API key. Make some fake API key models and continue on through
         // the process.
diff --git a/app/Http/Middleware/VerifyCsrfToken.php b/app/Http/Middleware/VerifyCsrfToken.php
index 08d960353..de10dc1a8 100644
--- a/app/Http/Middleware/VerifyCsrfToken.php
+++ b/app/Http/Middleware/VerifyCsrfToken.php
@@ -1,19 +1,46 @@
 <?php
 
 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);
+    }
 }
diff --git a/resources/scripts/api/http.ts b/resources/scripts/api/http.ts
index a642bb16e..d9f64ede2 100644
--- a/resources/scripts/api/http.ts
+++ b/resources/scripts/api/http.ts
@@ -5,14 +5,25 @@ const http: AxiosInstance = axios.create({
     timeout: 20000,
     headers: {
         '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();
     }
 
diff --git a/resources/views/admin/nodes/view/configuration.blade.php b/resources/views/admin/nodes/view/configuration.blade.php
index 9c7e987b5..35e3f5f0a 100644
--- a/resources/views/admin/nodes/view/configuration.blade.php
+++ b/resources/views/admin/nodes/view/configuration.blade.php
@@ -68,11 +68,15 @@
 
 @section('footer-scripts')
     @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.',
                 text: '<p>To auto-configure your node run the following command:<br /><small><pre>cd /etc/pterodactyl && sudo wings configure --panel-url {{ config('app.url') }} --token ' + data.token + ' --node ' + data.node + '{{ config('app.debug') ? ' --allow-insecure' : '' }}</pre></small></p>',
                 html: true
diff --git a/resources/views/admin/settings/mail.blade.php b/resources/views/admin/settings/mail.blade.php
index ffe90ea2a..580271e02 100644
--- a/resources/views/admin/settings/mail.blade.php
+++ b/resources/views/admin/settings/mail.blade.php
@@ -143,13 +143,13 @@
                 confirmButtonText: 'Test',
                 closeOnConfirm: false,
                 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 () {
                     swal({
                         title: 'Success',
diff --git a/routes/admin.php b/routes/admin.php
index 2deafb29f..fb45e6294 100644
--- a/routes/admin.php
+++ b/routes/admin.php
@@ -64,12 +64,12 @@ 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');
     Route::patch('/advanced', 'Settings\AdvancedController@update');
 });
@@ -151,16 +151,16 @@ Route::group(['prefix' => 'nodes'], function () {
     Route::get('/view/{node}/settings', 'Nodes\NodeViewController@settings')->name('admin.nodes.view.settings');
     Route::get('/view/{node}/configuration', 'Nodes\NodeViewController@configuration')->name('admin.nodes.view.configuration');
     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');
 
     Route::delete('/view/{node}/delete', 'NodesController@delete')->name('admin.nodes.view.delete');
     Route::delete('/view/{node}/allocation/remove/{allocation}', 'NodesController@allocationRemoveSingle')->name('admin.nodes.view.allocation.removeSingle');

Vulnerable code:             throw new HttpException(401, null, null, ['WWW-Authenticate' => 'Bearer']);
     * The URIs that should be excluded from CSRF verification.
     * @var array
    protected $except = [
        'remote/*',
        'daemon/*',
        'api/*',
    ];
        'X-CSRF-Token': (window as any).X_CSRF_TOKEN as string || '',
        $.getJSON('{{ route('admin.nodes.view.configuration.token', $node->id) }}').done(function (data) {
                    method: 'GET',
                    headers: { 'X-CSRF-Token': $('input[name="_token"]').val() }
    Route::get('/mail/test', 'Settings\MailController@test')->name('admin.settings.mail.test');
    Route::get('/view/{node}/settings/token', 'NodeAutoDeployController')->name('admin.nodes.view.configuration.token');
Fixed code:             VerifyCsrfToken::class,
            VerifyCsrfToken::class,
use Illuminate\Support\Facades\Session;
            throw new HttpException(401, 'A bearer token or valid user session cookie must be provided to access this endpoint.', null, ['WWW-Authenticate' => 'Bearer']);
use Closure;
use Pterodactyl\Models\ApiKey;
     * 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 string[]
    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);
    }
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;
});

        $.ajax({
            method: 'POST',
            url: '{{ route('admin.nodes.view.configuration.token', $node->id) }}',
            headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
        }).done(function (data) {
                    method: 'POST',
                    headers: { 'X-CSRF-TOKEN': $('input[name="_token"]').val() }
    Route::post('/mail/test', 'Settings\MailController@test')->name('admin.settings.mail.test');
    Route::post('/view/{node}/settings/token', 'NodeAutoDeployController')->name('admin.nodes.view.configuration.token');