Skip to content

Commit bf9cbe2

Browse files
committed
Add consistent CSRF token verification to API endpoints; address security concern with non-CSRF protected endpoints
1 parent cc31a0a commit bf9cbe2

File tree

7 files changed

+59
-14
lines changed

7 files changed

+59
-14
lines changed

app/Http/Kernel.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ class Kernel extends HttpKernel
7575
ApiSubstituteBindings::class,
7676
'api..key:' . ApiKey::TYPE_APPLICATION,
7777
AuthenticateApplicationUser::class,
78+
VerifyCsrfToken::class,
7879
AuthenticateIPAccess::class,
7980
],
8081
'client-api' => [
@@ -85,6 +86,7 @@ class Kernel extends HttpKernel
8586
SubstituteClientApiBindings::class,
8687
'api..key:' . ApiKey::TYPE_ACCOUNT,
8788
AuthenticateIPAccess::class,
89+
VerifyCsrfToken::class,
8890
// This is perhaps a little backwards with the Client API, but logically you'd be unable
8991
// to create/get an API key without first enabling 2FA on the account, so I suppose in the
9092
// end it makes sense.

app/Http/Middleware/Api/AuthenticateKey.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use Pterodactyl\Models\User;
99
use Pterodactyl\Models\ApiKey;
1010
use Illuminate\Auth\AuthManager;
11+
use Illuminate\Support\Facades\Session;
1112
use Illuminate\Contracts\Encryption\Encrypter;
1213
use Symfony\Component\HttpKernel\Exception\HttpException;
1314
use Pterodactyl\Exceptions\Repository\RecordNotFoundException;
@@ -55,7 +56,7 @@ public function __construct(ApiKeyRepositoryInterface $repository, AuthManager $
5556
public function handle(Request $request, Closure $next, int $keyType)
5657
{
5758
if (is_null($request->bearerToken()) && is_null($request->user())) {
58-
throw new HttpException(401, null, null, ['WWW-Authenticate' => 'Bearer']);
59+
throw new HttpException(401, 'A bearer token or valid user session cookie must be provided to access this endpoint.', null, ['WWW-Authenticate' => 'Bearer']);
5960
}
6061

6162
// This is a request coming through using cookies, we have an authenticated user

app/Http/Middleware/VerifyCsrfToken.php

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,45 @@
22

33
namespace Pterodactyl\Http\Middleware;
44

5+
use Closure;
6+
use Pterodactyl\Models\ApiKey;
57
use Illuminate\Foundation\Http\Middleware\VerifyCsrfToken as BaseVerifier;
68

79
class VerifyCsrfToken extends BaseVerifier
810
{
911
/**
10-
* The URIs that should be excluded from CSRF verification.
12+
* The URIs that should be excluded from CSRF verification. These are
13+
* never hit by the front-end, and require specific token validation
14+
* to work.
1115
*
12-
* @var array
16+
* @var string[]
1317
*/
14-
protected $except = [
15-
'remote/*',
16-
'daemon/*',
17-
'api/*',
18-
];
18+
protected $except = ['remote/*', 'daemon/*'];
19+
20+
/**
21+
* Manually apply CSRF protection to routes depending on the authentication
22+
* mechanism being used. If the API request is using an API key that exists
23+
* in the database we can safely ignore CSRF protections, since that would be
24+
* a manually initiated request by a user or server.
25+
*
26+
* All other requests should go through the standard CSRF protections that
27+
* Laravel affords us. This code will be removed in v2 since we have switched
28+
* to using Sanctum for the API endpoints, which handles that for us automatically.
29+
*
30+
* @param \Illuminate\Http\Request $request
31+
* @param \Closure $next
32+
* @return mixed
33+
*
34+
* @throws \Illuminate\Session\TokenMismatchException
35+
*/
36+
public function handle($request, Closure $next)
37+
{
38+
$key = $request->attributes->get('api_key');
39+
40+
if ($key instanceof ApiKey && $key->exists) {
41+
return $next($request);
42+
}
43+
44+
return parent::handle($request, $next);
45+
}
1946
}

resources/scripts/api/http.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,21 @@ const http: AxiosInstance = axios.create({
77
'X-Requested-With': 'XMLHttpRequest',
88
Accept: 'application/json',
99
'Content-Type': 'application/json',
10-
'X-CSRF-Token': (window as any).X_CSRF_TOKEN as string || '',
1110
},
1211
});
1312

13+
http.interceptors.request.use(req => {
14+
const cookies = document.cookie.split(';').reduce((obj, val) => {
15+
const [ key, value ] = val.trim().split('=').map(decodeURIComponent);
16+
17+
return { ...obj, [key]: value };
18+
}, {} as Record<string, string>);
19+
20+
req.headers['X-XSRF-TOKEN'] = cookies['XSRF-TOKEN'] || 'nil';
21+
22+
return req;
23+
});
24+
1425
http.interceptors.request.use(req => {
1526
if (!req.url?.endsWith('/resources') && (req.url?.indexOf('_debugbar') || -1) < 0) {
1627
store.getActions().progress.startContinuous();

resources/views/admin/nodes/view/configuration.blade.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,11 @@
7070
@parent
7171
<script>
7272
$('#configTokenBtn').on('click', function (event) {
73-
$.getJSON('{{ route('admin.nodes.view.configuration.token', $node->id) }}').done(function (data) {
73+
$.ajax({
74+
method: 'POST',
75+
url: '{{ route('admin.nodes.view.configuration.token', $node->id) }}',
76+
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}' },
77+
}).done(function (data) {
7478
swal({
7579
type: 'success',
7680
title: 'Token created.',

resources/views/admin/settings/mail.blade.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -145,9 +145,9 @@ function testSettings() {
145145
showLoaderOnConfirm: true
146146
}, function () {
147147
$.ajax({
148-
method: 'GET',
148+
method: 'POST',
149149
url: '/admin/settings/mail/test',
150-
headers: { 'X-CSRF-Token': $('input[name="_token"]').val() }
150+
headers: { 'X-CSRF-TOKEN': $('input[name="_token"]').val() }
151151
}).fail(function (jqXHR) {
152152
showErrorDialog(jqXHR, 'test');
153153
}).done(function () {

routes/admin.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,8 @@
6666
Route::group(['prefix' => 'settings'], function () {
6767
Route::get('/', 'Settings\IndexController@index')->name('admin.settings');
6868
Route::get('/mail', 'Settings\MailController@index')->name('admin.settings.mail');
69-
Route::get('/mail/test', 'Settings\MailController@test')->name('admin.settings.mail.test');
7069
Route::get('/advanced', 'Settings\AdvancedController@index')->name('admin.settings.advanced');
70+
Route::post('/mail/test', 'Settings\MailController@test')->name('admin.settings.mail.test');
7171

7272
Route::patch('/', 'Settings\IndexController@update');
7373
Route::patch('/mail', 'Settings\MailController@update');
@@ -153,12 +153,12 @@
153153
Route::get('/view/{node}/allocation', 'Nodes\NodeViewController@allocations')->name('admin.nodes.view.allocation');
154154
Route::get('/view/{node}/servers', 'Nodes\NodeViewController@servers')->name('admin.nodes.view.servers');
155155
Route::get('/view/{node}/system-information', 'Nodes\SystemInformationController');
156-
Route::get('/view/{node}/settings/token', 'NodeAutoDeployController')->name('admin.nodes.view.configuration.token');
157156

158157
Route::post('/new', 'NodesController@store');
159158
Route::post('/view/{node}/allocation', 'NodesController@createAllocation');
160159
Route::post('/view/{node}/allocation/remove', 'NodesController@allocationRemoveBlock')->name('admin.nodes.view.allocation.removeBlock');
161160
Route::post('/view/{node}/allocation/alias', 'NodesController@allocationSetAlias')->name('admin.nodes.view.allocation.setAlias');
161+
Route::post('/view/{node}/settings/token', 'NodeAutoDeployController')->name('admin.nodes.view.configuration.token');
162162

163163
Route::patch('/view/{node}/settings', 'NodesController@updateSettings');
164164

0 commit comments

Comments
 (0)