Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[5.x] Decouple CSRF token from nocache script #11014

Open
wants to merge 13 commits into
base: 5.x
Choose a base branch
from
2 changes: 1 addition & 1 deletion config/static_caching.php
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@

'nocache' => 'cache',

'nocache_js_position' => 'body',
'decouple_nocache_scripts' => false,

/*
|--------------------------------------------------------------------------
Expand Down
15 changes: 9 additions & 6 deletions routes/web.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
use Statamic\Http\Middleware\AuthGuard;
use Statamic\Http\Middleware\CP\AuthGuard as CPAuthGuard;
use Statamic\Statamic;
use Statamic\StaticCaching\NoCache\Controller as NoCacheController;
use Statamic\StaticCaching\NoCache\CsrfTokenController;
use Statamic\StaticCaching\NoCache\NoCacheController;
use Statamic\StaticCaching\NoCache\NoCacheLocalize;

Route::name('statamic.')->group(function () {
Expand Down Expand Up @@ -49,14 +50,16 @@
Route::post('activate', [ActivateAccountController::class, 'reset'])->name('account.activate.action');
});

Route::post('nocache', NoCacheController::class)
->middleware(NoCacheLocalize::class)
->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken']);

Route::post('csrf', CsrfTokenController::class)
->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken']);

Statamic::additionalActionRoutes();
});

Route::prefix(config('statamic.routes.action'))
->post('nocache', NoCacheController::class)
->middleware(NoCacheLocalize::class)
->withoutMiddleware(['App\Http\Middleware\VerifyCsrfToken', 'Illuminate\Foundation\Http\Middleware\VerifyCsrfToken']);

if (OAuth::enabled()) {
Route::get(config('statamic.oauth.routes.login'), [OAuthController::class, 'redirectToProvider'])->name('oauth.login');
Route::match(['get', 'post'], config('statamic.oauth.routes.callback'), [OAuthController::class, 'handleProviderCallback'])
Expand Down
1 change: 1 addition & 0 deletions src/Facades/StaticCache.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* @method static ApplicationCacher createApplicationDriver(array $config)
* @method static \Illuminate\Cache\Repository cacheStore()
* @method static void flush()
* @method static void csrfTokenJs(string $js)
* @method static void nocacheJs(string $js)
* @method static void nocachePlaceholder(string $placeholder)
* @method static void includeJs()
Expand Down
100 changes: 97 additions & 3 deletions src/StaticCaching/Cachers/FileCacher.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ class FileCacher extends AbstractCacher
*/
private $shouldOutputJs = false;

/**
* @var string
*/
private $csrfTokenJs;

/**
* @var string
*/
Expand Down Expand Up @@ -196,16 +201,107 @@ private function isLongQueryStringPath($path)
return Str::contains($path, '_lqs_');
}

public function setCsrfTokenJs(string $js)
{
$this->csrfTokenJs = $js;
}

public function setNocacheJs(string $js)
{
$this->nocacheJs = $js;
}

public function getNocacheJs(): string
public function getCsrfTokenJs(): string
{
$csrfPlaceholder = CsrfTokenReplacer::REPLACEMENT;

$default = <<<EOT
(function() {
fetch('/!/csrf', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
.then((response) => response.json())
.then((data) => {
for (const input of document.querySelectorAll('input[value="$csrfPlaceholder"]')) {
input.value = data.csrf;
}

for (const meta of document.querySelectorAll('meta[content="$csrfPlaceholder"]')) {
meta.content = data.csrf;
}

for (const input of document.querySelectorAll('script[data-csrf="$csrfPlaceholder"]')) {
input.setAttribute('data-csrf', data.csrf);
}

if (window.hasOwnProperty('livewire_token')) {
window.livewire_token = data.csrf
}

if (window.hasOwnProperty('livewireScriptConfig')) {
window.livewireScriptConfig.csrf = data.csrf
}

document.dispatchEvent(new CustomEvent('statamic:csrf.replaced', { detail: data }));
});
})();
EOT;

return $this->csrfTokenJs ?? $default;
}

public function shouldOutputDecoupledScripts(): bool
{
return config('statamic.static_caching.decouple_nocache_scripts', false);
}

public function getNocacheJs(): string
{
$default = $this->shouldOutputDecoupledScripts()
? $this->getDecoupledNocacheJs()
: $this->getLegacyNocacheJs();

return $this->nocacheJs ?? $default;
}

protected function getDecoupledNocacheJs(): string
{
return <<<'EOT'
(function() {
var els = document.getElementsByClassName('nocache');
var map = {};
for (var i = 0; i < els.length; i++) {
var section = els[i].getAttribute('data-nocache');
map[section] = els[i];
}

fetch('/!/nocache', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
url: window.location.href.split('#')[0],
sections: Object.keys(map)
})
})
.then((response) => response.json())
.then((data) => {
const regions = data.regions;
for (var key in regions) {
if (map[key]) map[key].outerHTML = regions[key];
}

document.dispatchEvent(new CustomEvent('statamic:nocache.replaced', { detail: data }));
});
})();
EOT;
}

protected function getLegacyNocacheJs(): string
{
$csrfPlaceholder = CsrfTokenReplacer::REPLACEMENT;

return <<<EOT
(function() {
var els = document.getElementsByClassName('nocache');
var map = {};
Expand Down Expand Up @@ -253,8 +349,6 @@ public function getNocacheJs(): string
});
})();
EOT;

return $this->nocacheJs ?? $default;
}

public function shouldOutputJs(): bool
Expand Down
13 changes: 13 additions & 0 deletions src/StaticCaching/NoCache/CsrfTokenController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?php

namespace Statamic\StaticCaching\NoCache;

class CsrfTokenController
{
public function __invoke()
{
return [
'csrf' => csrf_token(),
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
use Statamic\StaticCaching\Replacers\NoCacheReplacer;
use Statamic\Support\Str;

class Controller
class NoCacheController
{
public function __invoke(Request $request, Session $session)
{
Expand Down
54 changes: 47 additions & 7 deletions src/StaticCaching/Replacers/CsrfTokenReplacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

use Illuminate\Http\Response;
use Statamic\Facades\StaticCache;
use Statamic\StaticCaching\Cacher;
use Statamic\StaticCaching\Cachers\FileCacher;
use Statamic\StaticCaching\Replacer;
use Statamic\Support\Str;

Expand All @@ -12,6 +14,26 @@ class CsrfTokenReplacer implements Replacer
const REPLACEMENT = 'STATAMIC_CSRF_TOKEN';

public function prepareResponseToCache(Response $response, Response $initial)
{
$this->replaceInResponse($response);

$this->modifyFullMeasureResponse($response);
}

public function replaceInCachedResponse(Response $response)
{
if (! $response->getContent()) {
return;
}

$response->setContent(str_replace(
self::REPLACEMENT,
csrf_token(),
$response->getContent()
));
}

private function replaceInResponse(Response $response)
{
if (! $content = $response->getContent()) {
return;
Expand All @@ -34,16 +56,34 @@ public function prepareResponseToCache(Response $response, Response $initial)
));
}

public function replaceInCachedResponse(Response $response)
private function modifyFullMeasureResponse(Response $response)
{
if (! $response->getContent()) {
$cacher = app(Cacher::class);

if (! $cacher instanceof FileCacher) {
return;
}

$response->setContent(str_replace(
self::REPLACEMENT,
csrf_token(),
$response->getContent()
));
if (! $cacher->shouldOutputJs()) {
return;
}

if (! $cacher->shouldOutputDecoupledScripts()) {
return;
}

$contents = $response->getContent();

$insertBefore = collect([
Str::position($contents, '<link'),
Str::position($contents, '<script'),
Str::position($contents, '</head>'),
])->filter()->min();

$js = "<script type=\"text/javascript\">{$cacher->getCsrfTokenJs()}</script>";

$contents = Str::substrReplace($contents, $js, $insertBefore, 0);

$response->setContent($contents);
}
}
33 changes: 2 additions & 31 deletions src/StaticCaching/Replacers/NoCacheReplacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
namespace Statamic\StaticCaching\Replacers;

use Illuminate\Http\Response;
use Illuminate\Support\Str;
use Statamic\Facades\StaticCache;
use Statamic\StaticCaching\Cacher;
use Statamic\StaticCaching\Cachers\FileCacher;
Expand Down Expand Up @@ -79,40 +78,12 @@ private function modifyFullMeasureResponse(Response $response)
$contents = $response->getContent();

if ($cacher->shouldOutputJs()) {
$contents = match ($pos = $this->insertPosition()) {
'head' => $this->insertJsInHead($contents, $cacher),
'body' => $this->insertJsInBody($contents, $cacher),
default => throw new \Exception('Invalid nocache js insert position ['.$pos.']'),
};
$js = $cacher->getNocacheJs();
$contents = str_replace('</body>', '<script type="text/javascript">'.$js.'</script></body>', $contents);
}

$contents = str_replace('NOCACHE_PLACEHOLDER', $cacher->getNocachePlaceholder(), $contents);

$response->setContent($contents);
}

private function insertPosition()
{
return config('statamic.static_caching.nocache_js_position', 'body');
}

private function insertJsInHead($contents, $cacher)
{
$insertBefore = collect([
Str::position($contents, '<link'),
Str::position($contents, '<script'),
Str::position($contents, '</head>'),
])->filter()->min();

$js = "<script type=\"text/javascript\">{$cacher->getNocacheJs()}</script>";

return Str::substrReplace($contents, $js, $insertBefore, 0);
}

private function insertJsInBody($contents, $cacher)
{
$js = $cacher->getNocacheJs();

return str_replace('</body>', '<script type="text/javascript">'.$js.'</script></body>', $contents);
}
}
5 changes: 5 additions & 0 deletions src/StaticCaching/StaticCacheManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ private function flushNocache()
$this->cacheStore()->forget('nocache::urls');
}

public function csrfTokenJs(string $js)
{
$this->fileDriver()->setCsrfTokenJs($js);
}

public function nocacheJs(string $js)
{
$this->fileDriver()->setNocacheJs($js);
Expand Down
33 changes: 33 additions & 0 deletions tests/StaticCaching/FullMeasureStaticCachingTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use PHPUnit\Framework\Attributes\Test;
use Statamic\Facades\File;
use Statamic\Facades\StaticCache;
use Statamic\StaticCaching\Cacher;
use Statamic\StaticCaching\NoCache\Session;
use Tests\FakesContent;
use Tests\FakesViews;
Expand Down Expand Up @@ -157,4 +158,36 @@ public function it_should_add_the_javascript_if_there_is_a_csrf_token()
'<script type="text/javascript">js here</script>',
]), file_get_contents($this->dir.'/about_.html'));
}

#[Test]
public function it_decouples_csrf_and_nocache_scripts_if_option_is_enabled()
{
$this->app['config']->set('statamic.static_caching.decouple_nocache_scripts', true);

$this->withFakeViews();
$this->viewShouldReturnRaw('layout', '<html><head></head><body>{{ template_content }}</body></html>');
$this->viewShouldReturnRaw('default', '{{ csrf_token }}');

$this->createPage('about');

StaticCache::nocacheJs('js here');

$csrfTokenScript = '<script type="text/javascript">'.app(Cacher::class)->getCsrfTokenJs().'</script>';
$nocacheScript = '<script type="text/javascript">'.app(Cacher::class)->getNocacheJs().'</script>';

$this->assertFalse(file_exists($this->dir.'/about_.html'));

$response = $this
->get('/about')
->assertOk();

// Initial response should be dynamic and not contain javascript.
$this->assertEquals('<html><head></head><body>'.csrf_token().'</body></html>', $response->getContent());

// The cached response should have the token placeholder, and the javascript.
$this->assertTrue(file_exists($this->dir.'/about_.html'));
$this->assertEquals(vsprintf("<html><head>{$csrfTokenScript}</head><body>STATAMIC_CSRF_TOKEN%s</body></html>", [
$nocacheScript,
]), file_get_contents($this->dir.'/about_.html'));
}
}
Loading