Skip to content

Commit 7c58d74

Browse files
authored
Merge pull request #1454 from hydephp/realtime-compiler-security
Add realtime compiler dashboard CSRF protection
2 parents ddd0c75 + abca991 commit 7c58d74

File tree

4 files changed

+130
-62
lines changed

4 files changed

+130
-62
lines changed

packages/realtime-compiler/resources/dashboard.blade.php

+5
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<head>
55
<meta charset="UTF-8">
66
<meta name="viewport" content="width=device-width, initial-scale=1">
7+
<meta name="csrf-token" content="{{ $csrfToken }}">
78
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
89
<title>{{ $title }}</title>
910
<base target="_parent">
@@ -64,6 +65,7 @@
6465
<h2 class="h5 mb-0">Site Pages & Routes</h2>
6566
@if($dashboard->isInteractive())
6667
<form class="buttonActionForm" action="" method="POST">
68+
<input type="hidden" name="_token" value="{{ $csrfToken }}">
6769
<input type="hidden" name="action" value="openInExplorer">
6870
<button type="submit" class="btn btn-outline-primary btn-sm" title="Open project in system file explorer">Open folder</button>
6971
</form>
@@ -136,6 +138,7 @@
136138
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
137139
</div>
138140
<form id="createPageForm" action="" method="POST">
141+
<input type="hidden" name="_token" value="{{ $csrfToken }}">
139142
<input type="hidden" name="action" value="createPage">
140143

141144
<div class="modal-body">
@@ -242,6 +245,7 @@
242245
<div class="d-flex justify-content-end">
243246
@if($dashboard->isInteractive())
244247
<form class="buttonActionForm" action="" method="POST">
248+
<input type="hidden" name="_token" value="{{ $csrfToken }}">
245249
<input type="hidden" name="action" value="openPageInEditor">
246250
<input type="hidden" name="routeKey" value="{{ $route->getRouteKey() }}">
247251
<button type="submit" class="btn btn-outline-primary btn-sm me-2" title="Open in system default application">Edit</button>
@@ -302,6 +306,7 @@
302306
@if($dashboard->isInteractive())
303307
<div class="w-auto ps-0">
304308
<form class="buttonActionForm" action="" method="POST">
309+
<input type="hidden" name="_token" value="{{ $csrfToken }}">
305310
<input type="hidden" name="action" value="openMediaFileInEditor">
306311
<input type="hidden" name="identifier" value="{{ $mediaFile->getIdentifier() }}">
307312
<button type="submit" class="btn btn-link btn-sm py-0" title="Open this image in the system editor">Edit</button>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Hyde\RealtimeCompiler\Http;
6+
7+
use Desilva\Microserve\Request;
8+
use Desilva\Microserve\Response;
9+
use Desilva\Microserve\JsonResponse;
10+
use Hyde\RealtimeCompiler\ConsoleOutput;
11+
use Symfony\Component\HttpKernel\Exception\HttpException;
12+
13+
/**
14+
* @internal This class is not intended to be edited outside the Hyde Realtime Compiler.
15+
*/
16+
abstract class BaseController
17+
{
18+
protected Request $request;
19+
protected ConsoleOutput $console;
20+
protected bool $withConsoleOutput = false;
21+
protected bool $withSession = false;
22+
23+
abstract public function handle(): Response;
24+
25+
public function __construct(?Request $request = null)
26+
{
27+
$this->request = $request ?? Request::capture();
28+
29+
if ($this->withConsoleOutput && ((bool) env('HYDE_SERVER_REQUEST_OUTPUT', false)) === true) {
30+
$this->console = new ConsoleOutput();
31+
}
32+
33+
if ($this->withSession) {
34+
session_start();
35+
}
36+
}
37+
38+
protected function sendJsonErrorResponse(int $statusCode, string $message): JsonResponse
39+
{
40+
return new JsonResponse($statusCode, $this->matchStatusCode($statusCode), [
41+
'error' => $message,
42+
]);
43+
}
44+
45+
protected function abort(int $code, string $message): never
46+
{
47+
throw new HttpException($code, $message);
48+
}
49+
50+
protected function matchStatusCode(int $statusCode): string
51+
{
52+
return match ($statusCode) {
53+
200 => 'OK',
54+
201 => 'Created',
55+
400 => 'Bad Request',
56+
403 => 'Forbidden',
57+
404 => 'Not Found',
58+
409 => 'Conflict',
59+
default => 'Internal Server Error',
60+
};
61+
}
62+
63+
protected function authorizePostRequest(): void
64+
{
65+
if (! $this->isRequestMadeFromLocalhost()) {
66+
throw new HttpException(403, "Refusing to serve request from address {$_SERVER['REMOTE_ADDR']} (must be on localhost)");
67+
}
68+
69+
if ($this->withSession) {
70+
if (! $this->validateCSRFToken($this->request->get('_token'))) {
71+
throw new HttpException(403, 'Invalid CSRF token');
72+
}
73+
}
74+
}
75+
76+
protected function isRequestMadeFromLocalhost(): bool
77+
{
78+
// As the dashboard is not password-protected, and it can make changes to the file system,
79+
// we block any requests that are not coming from the host machine. While we are clear
80+
// in the documentation that the realtime compiler should only be used for local
81+
// development, we still want to be extra careful in case someone forgets.
82+
83+
$requestIp = $_SERVER['REMOTE_ADDR'];
84+
$allowedIps = ['::1', '127.0.0.1', 'localhost'];
85+
86+
return in_array($requestIp, $allowedIps, true);
87+
}
88+
89+
protected function generateCSRFToken(): string
90+
{
91+
if (empty($_SESSION['csrf_token'])) {
92+
$_SESSION['csrf_token'] = bin2hex(random_bytes(32));
93+
}
94+
95+
return $_SESSION['csrf_token'];
96+
}
97+
98+
protected function validateCSRFToken(?string $suppliedToken): bool
99+
{
100+
if ($suppliedToken === null || empty($_SESSION['csrf_token'])) {
101+
return false;
102+
}
103+
104+
return hash_equals($_SESSION['csrf_token'], $suppliedToken);
105+
}
106+
107+
protected function writeToConsole(string $message, string $context = 'dashboard'): void
108+
{
109+
if (isset($this->console)) {
110+
$this->console->printMessage($message, $context);
111+
}
112+
}
113+
}

packages/realtime-compiler/src/Http/DashboardController.php

+11-61
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use Illuminate\Support\Arr;
1212
use Hyde\Pages\MarkdownPage;
1313
use Hyde\Pages\MarkdownPost;
14+
use Desilva\Microserve\Request;
1415
use Desilva\Microserve\Response;
1516
use Hyde\Pages\Concerns\HydePage;
1617
use Hyde\Pages\DocumentationPage;
@@ -20,10 +21,8 @@
2021
use Desilva\Microserve\JsonResponse;
2122
use Hyde\Support\Filesystem\MediaFile;
2223
use Illuminate\Support\Facades\Process;
23-
use Hyde\RealtimeCompiler\ConsoleOutput;
2424
use Hyde\Framework\Actions\StaticPageBuilder;
2525
use Hyde\Framework\Actions\AnonymousViewCompiler;
26-
use Desilva\Microserve\Request;
2726
use Composer\InstalledVersions;
2827
use Hyde\Framework\Actions\CreatesNewPageSourceFile;
2928
use Hyde\Framework\Exceptions\FileConflictException;
@@ -33,12 +32,13 @@
3332
/**
3433
* @internal This class is not intended to be edited outside the Hyde Realtime Compiler.
3534
*/
36-
class DashboardController
35+
class DashboardController extends BaseController
3736
{
3837
public string $title;
3938

40-
protected Request $request;
41-
protected ConsoleOutput $console;
39+
protected bool $withConsoleOutput = true;
40+
protected bool $withSession = true;
41+
4242
protected JsonResponse $response;
4343

4444
protected bool $isAsync = false;
@@ -52,14 +52,11 @@ class DashboardController
5252
'The dashboard update your project files. You can disable this by setting `server.dashboard.interactive` to `false` in `config/hyde.php`.',
5353
];
5454

55-
public function __construct()
55+
public function __construct(?Request $request = null)
5656
{
57-
$this->title = config('hyde.name').' - Dashboard';
58-
$this->request = Request::capture();
57+
parent::__construct($request);
5958

60-
if (((bool) env('HYDE_SERVER_REQUEST_OUTPUT', false)) === true) {
61-
$this->console = new ConsoleOutput();
62-
}
59+
$this->title = config('hyde.name').' - Dashboard';
6360

6461
$this->loadFlashData();
6562

@@ -75,11 +72,9 @@ public function handle(): Response
7572
return $this->sendJsonErrorResponse(403, 'Enable `server.editor` in `config/hyde.php` to use interactive dashboard features.');
7673
}
7774

78-
if ($this->shouldUnsafeRequestBeBlocked()) {
79-
return $this->sendJsonErrorResponse(403, "Refusing to serve request from address {$_SERVER['REMOTE_ADDR']} (must be on localhost)");
80-
}
81-
8275
try {
76+
$this->authorizePostRequest();
77+
8378
return $this->handlePostRequest();
8479
} catch (HttpException $exception) {
8580
if (! $this->isAsync) {
@@ -98,7 +93,7 @@ public function handle(): Response
9893
protected function show(): string
9994
{
10095
return AnonymousViewCompiler::handle(__DIR__.'/../../resources/dashboard.blade.php', array_merge(
101-
(array) $this, ['dashboard' => $this, 'request' => $this->request],
96+
(array) $this, ['dashboard' => $this, 'request' => $this->request, 'csrfToken' => $this->generateCSRFToken()],
10297
));
10398
}
10499

@@ -451,38 +446,13 @@ protected static function getPackageVersion(string $packageName): string
451446
return $prettyVersion ?? 'unreleased';
452447
}
453448

454-
protected function shouldUnsafeRequestBeBlocked(): bool
455-
{
456-
// As the dashboard is not password-protected, and it can make changes to the file system,
457-
// we block any requests that are not coming from the host machine. While we are clear
458-
// in the documentation that the realtime compiler should only be used for local
459-
// development, we still want to be extra careful in case someone forgets.
460-
461-
$requestIp = $_SERVER['REMOTE_ADDR'];
462-
$allowedIps = ['::1', '127.0.0.1', 'localhost'];
463-
464-
return ! in_array($requestIp, $allowedIps, true);
465-
}
466-
467449
protected function setJsonResponse(int $statusCode, string $body): void
468450
{
469451
$this->response = new JsonResponse($statusCode, $this->matchStatusCode($statusCode), [
470452
'body' => $body,
471453
]);
472454
}
473455

474-
protected function sendJsonErrorResponse(int $statusCode, string $message): JsonResponse
475-
{
476-
return new JsonResponse($statusCode, $this->matchStatusCode($statusCode), [
477-
'error' => $message,
478-
]);
479-
}
480-
481-
protected function abort(int $code, string $message): never
482-
{
483-
throw new HttpException($code, $message);
484-
}
485-
486456
protected function findGeneralOpenBinary(): string
487457
{
488458
return match (PHP_OS_FAMILY) {
@@ -496,28 +466,8 @@ protected function findGeneralOpenBinary(): string
496466
};
497467
}
498468

499-
protected function matchStatusCode(int $statusCode): string
500-
{
501-
return match ($statusCode) {
502-
200 => 'OK',
503-
201 => 'Created',
504-
400 => 'Bad Request',
505-
403 => 'Forbidden',
506-
404 => 'Not Found',
507-
409 => 'Conflict',
508-
default => 'Internal Server Error',
509-
};
510-
}
511-
512469
protected function hasAsyncHeaders(): bool
513470
{
514471
return (getallheaders()['X-RC-Handler'] ?? getallheaders()['x-rc-handler'] ?? null) === 'Async';
515472
}
516-
517-
protected function writeToConsole(string $message, string $context = 'dashboard'): void
518-
{
519-
if (isset($this->console)) {
520-
$this->console->printMessage($message, $context);
521-
}
522-
}
523473
}

packages/realtime-compiler/src/Routing/PageRouter.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ public function __construct(Request $request)
3333
protected function handlePageRequest(): Response
3434
{
3535
if ($this->request->path === '/dashboard' && DashboardController::enabled()) {
36-
return (new DashboardController())->handle();
36+
return (new DashboardController($this->request))->handle();
3737
}
3838

3939
return new HtmlResponse(200, 'OK', [

0 commit comments

Comments
 (0)