diff --git a/core/src/Providers/ArtisanServiceProvider.php b/core/src/Providers/ArtisanServiceProvider.php index 57f7637751..4040ea8087 100644 --- a/core/src/Providers/ArtisanServiceProvider.php +++ b/core/src/Providers/ArtisanServiceProvider.php @@ -9,6 +9,7 @@ use Illuminate\Console\Scheduling\ScheduleFinishCommand; use Illuminate\Console\Scheduling\ScheduleTestCommand; use Illuminate\Console\Scheduling\ScheduleWorkCommand; +use Illuminate\Console\Signals; use Illuminate\Events\Dispatcher; use Illuminate\Support\ServiceProvider; use Illuminate\Database\Console\Seeds\SeedCommand; @@ -22,6 +23,7 @@ use Illuminate\Database\Console\Migrations\RefreshCommand as MigrateRefreshCommand; use Illuminate\Database\Console\Migrations\RollbackCommand as MigrateRollbackCommand; use Illuminate\Database\Console\Migrations\MigrateMakeCommand; +use Symfony\Component\Console\SignalRegistry\SignalRegistry; use EvolutionCMS\Console\ClearCompiledCommand; use EvolutionCMS\Console\VendorPublishCommand; @@ -104,6 +106,8 @@ class ArtisanServiceProvider extends ServiceProvider */ public function register() { + Signals::resolveAvailabilityUsing(static fn () => SignalRegistry::isSupported()); + $hereCommands = array_merge( $this->commands, $this->devCommands ); diff --git a/core/tests/Unit/Console/ConsoleSignalsTest.php b/core/tests/Unit/Console/ConsoleSignalsTest.php new file mode 100644 index 0000000000..4cf969938e --- /dev/null +++ b/core/tests/Unit/Console/ConsoleSignalsTest.php @@ -0,0 +1,55 @@ +resolver = new ReflectionProperty(Signals::class, 'availabilityResolver'); + $this->previousResolver = $this->resolver->getValue(); + $this->resolver->setValue(null, null); + + (new ArtisanServiceProvider(new Container()))->register(); +}); + +afterEach(function () { + $this->resolver->setValue(null, $this->previousResolver); +}); + +test('console boot registers signal availability for the current runtime', function () { + $called = false; + + Signals::whenAvailable(function () use (&$called) { + $called = true; + }); + + expect($called)->toBe(function_exists('pcntl_signal')); +}); + +test('commands can trap and release signals after console boot', function () { + $command = new Command(); + $command->setApplication(new Application()); + $resolved = false; + $signal = defined('SIGUSR1') ? SIGUSR1 : 10; + $previousHandler = function_exists('pcntl_signal_get_handler') + ? pcntl_signal_get_handler($signal) + : null; + + try { + $command->trap(function () use (&$resolved, $signal) { + $resolved = true; + + return $signal; + }, static function () {}); + + expect($resolved)->toBe(function_exists('pcntl_signal')); + } finally { + $command->untrap(); + } + + if ($previousHandler !== null) { + expect(pcntl_signal_get_handler($signal))->toBe($previousHandler); + } +}); diff --git a/core/tests/Unit/Manager/ElementListCsrfTokenTest.php b/core/tests/Unit/Manager/ElementListCsrfTokenTest.php new file mode 100644 index 0000000000..d942658626 --- /dev/null +++ b/core/tests/Unit/Manager/ElementListCsrfTokenTest.php @@ -0,0 +1,62 @@ +makeUrl\(.*?)"/', $source, $matches); + + $item = new class { + use ManagerActions; + + public bool $exists = true; + public array $managerActionsMap; + + public function getKey(): int + { + return 42; + } + }; + $item->managerActionsMap = (new ReflectionClass($model))->getDefaultProperties()['managerActionsMap']; + $originalSession = $_SESSION ?? null; + $_SESSION = ['_token' => str_repeat('a', 40)]; + $compiler = new BladeCompiler(new Filesystem(), sys_get_temp_dir()); + $mutating = 0; + + try { + // The module name runs it for managers who can execute but cannot edit modules. + foreach (['actions.edit', 'actions.run'] as $action) { + foreach ($matches[1] as $fragment) { + ob_start(); + try { + eval('?>' . $compiler->compileString($fragment)); + $url = html_entity_decode(ob_get_contents(), ENT_QUOTES, 'UTF-8'); + } finally { + ob_end_clean(); + } + parse_str((string) parse_url($url, PHP_URL_QUERY), $query); + expect($query['id'])->toBe('42'); + + if ($query['a'] === (string) $item->managerActionsMap['id']['actions.edit']) { + expect($query)->not->toHaveKey('_token'); + continue; + } + + expect($query['_token'] ?? null)->toBe($_SESSION['_token']); + $mutating++; + } + } + expect($mutating)->toBe($expectedLinks); + } finally { + $_SESSION = $originalSession; + } +})->with([ + 'templates' => ['template', EvolutionCMS\Models\SiteTemplate::class, 4], + 'chunks' => ['chunk', EvolutionCMS\Models\SiteHtmlsnippet::class, 8], + 'snippets' => ['snippet', EvolutionCMS\Models\SiteSnippet::class, 8], + 'plugins' => ['plugin', EvolutionCMS\Models\SitePlugin::class, 8], + 'TVs' => ['tv', EvolutionCMS\Models\SiteTmplvar::class, 4], + 'modules' => ['module', EvolutionCMS\Models\SiteModule::class, 11], +]); diff --git a/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php b/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php index 6681767bda..848e980f65 100644 --- a/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php +++ b/core/tests/Unit/Security/ManagerCsrfMiddlewareTest.php @@ -201,6 +201,12 @@ function csrfPassed(Request $request): bool expect(csrfPassed(csrfRequest('GET', ['a' => 27, 'id' => 3, 'action' => 'delete'])))->toBeTrue(); }); +it('does not reach an element delete processor without the current session token', function (int $action) { + expect(csrfPassed(csrfRequest('GET', ['a' => $action, 'id' => 3])))->toBeFalse() + ->and(csrfPassed(csrfRequest('GET', ['a' => $action, 'id' => 3, '_token' => 'stale'])))->toBeFalse() + ->and(csrfPassed(csrfRequest('GET', ['a' => $action, 'id' => 3, '_token' => str_repeat('a', 40)])))->toBeTrue(); +})->with([21, 80, 25, 104, 303, 110]); + it('accepts destructive GET actions carrying the session token', function () { $request = csrfRequest('GET', ['a' => 112, 'id' => 3, '_token' => str_repeat('a', 40)]); diff --git a/manager/views/page/resources/elements/chunk.blade.php b/manager/views/page/resources/elements/chunk.blade.php index 3dd44da330..6cd5e1655f 100644 --- a/manager/views/page/resources/elements/chunk.blade.php +++ b/manager/views/page/resources/elements/chunk.blade.php @@ -42,10 +42,10 @@ onclick="actionDisableElement(this)" title="@if($item->disabled) {{ ManagerTheme::getLexicon('enable') }} @else {{ ManagerTheme::getLexicon('disable') }} @endif" data-disabled="{{ $item->disabled }}" - data-enable-href="{{ $item->makeUrl('actions.enable', false, ['disabled' => 0]) }}" + data-enable-href="{{ $item->makeUrl('actions.enable', false, ['disabled' => 0, '_token' => csrf_token()]) }}" data-enable-title="{{ ManagerTheme::getLexicon('enable') }}" data-enable-icon="{{ $_style['icon_enable'] }}" - data-disable-href="{{ $item->makeUrl('actions.disable', false, ['disabled' => 1]) }}" + data-disable-href="{{ $item->makeUrl('actions.disable', false, ['disabled' => 1, '_token' => csrf_token()]) }}" data-disable-title="{{ ManagerTheme::getLexicon('disable') }}" data-disable-icon="{{ $_style['icon_disable'] }}" > @@ -55,14 +55,14 @@ @endif @if(evolutionCMS()->hasPermission('new_chunk'))
  • - +
  • @endif @if(evolutionCMS()->hasPermission('delete_chunk'))
  • - +
  • diff --git a/manager/views/page/resources/elements/module.blade.php b/manager/views/page/resources/elements/module.blade.php index b29a5ba57c..ab6bcdfe6e 100644 --- a/manager/views/page/resources/elements/module.blade.php +++ b/manager/views/page/resources/elements/module.blade.php @@ -15,7 +15,7 @@ @if(empty($action)) @else - + @endif @if(empty($item->icon)) @@ -45,7 +45,7 @@