diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 33f9fb7938..1570be4019 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -6,6 +6,7 @@ namespace Icinga\Controllers; use Exception; +use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; use InvalidArgumentException; use Icinga\Application\Config; @@ -17,6 +18,7 @@ use Icinga\Forms\ActionForm; use Icinga\Forms\Config\GeneralConfigForm; use Icinga\Forms\Config\ResourceConfigForm; +use Icinga\Forms\Config\Security\CspConfigForm; use Icinga\Forms\Config\UserBackendConfigForm; use Icinga\Forms\Config\UserBackendReorderForm; use Icinga\Forms\ConfirmRemovalForm; @@ -25,6 +27,7 @@ use Icinga\Web\Notification; use Icinga\Web\Url; use Icinga\Web\Widget; +use ipl\Html\Contract\Form as ContractForm; /** * Application and module configuration @@ -39,17 +42,25 @@ public function createApplicationTabs() $tabs = $this->getTabs(); if ($this->hasPermission('config/general')) { $tabs->add('general', [ - 'title' => $this->translate('Adjust the general configuration of Icinga Web 2'), - 'label' => $this->translate('General'), - 'url' => 'config/general', + 'title' => $this->translate('Adjust the general configuration of Icinga Web 2'), + 'label' => $this->translate('General'), + 'url' => 'config/general', 'baseTarget' => '_main' ]); } + if ($this->hasPermission('config/security')) { + $tabs->add('security', array( + 'title' => $this->translate('Adjust the security configuration of Icinga Web 2'), + 'label' => $this->translate('Security'), + 'url' => 'config/security', + 'baseTarget' => '_main', + )); + } if ($this->hasPermission('config/resources')) { $tabs->add('resource', [ - 'title' => $this->translate('Configure which resources are being utilized by Icinga Web 2'), - 'label' => $this->translate('Resources'), - 'url' => 'config/resource', + 'title' => $this->translate('Configure which resources are being utilized by Icinga Web 2'), + 'label' => $this->translate('Resources'), + 'url' => 'config/resource', 'baseTarget' => '_main' ]); } @@ -57,9 +68,9 @@ public function createApplicationTabs() || $this->hasPermission('config/access-control/groups') ) { $tabs->add('authentication', [ - 'title' => $this->translate('Configure the user and group backends'), - 'label' => $this->translate('Access Control Backends'), - 'url' => 'config/userbackend', + 'title' => $this->translate('Configure the user and group backends'), + 'label' => $this->translate('Access Control Backends'), + 'url' => 'config/userbackend', 'baseTarget' => '_main' ]); } @@ -79,6 +90,8 @@ public function indexAction() { if ($this->hasPermission('config/general')) { $this->redirectNow('config/general'); + } elseif ($this->hasPermission('config/security')) { + $this->redirectNow('config/security'); } elseif ($this->hasPermission('config/resources')) { $this->redirectNow('config/resource'); } elseif ($this->hasPermission('config/access-control/*')) { @@ -96,26 +109,51 @@ public function indexAction() public function generalAction() { $this->assertPermission('config/general'); + + $this->view->title = $this->translate('General'); + $form = new GeneralConfigForm(); $form->setIniConfig(Config::app()); - $form->setOnSuccess(function (GeneralConfigForm $form) { - $config = Config::app(); - $useStrictCsp = (bool) $config->get('security', 'use_strict_csp', false); - if ($form->onSuccess() === false) { - return false; - } - - $appConfigForm = $form->getSubForm('form_config_general_application'); - if ($appConfigForm && (bool) $appConfigForm->getValue('security_use_strict_csp') !== $useStrictCsp) { - $this->getResponse()->setReloadWindow(true); - } - })->handleRequest(); + $form->handleRequest(); $this->view->form = $form; - $this->view->title = $this->translate('General'); + $this->createApplicationTabs()->activate('general'); } + /** + * Security configuration + * + * @return void + */ + public function securityAction(): void + { + $this->assertPermission('config/security'); + + $this->view->title = $this->translate('Security'); + + $config = Config::app(); + $cspForm = new CspConfigForm($config); + // Keep the auto-generation options off by default for installations that already + // had CSP enabled, so their existing behavior isn't changed. For installations + // enabling CSP for the first time, default them on as the recommended setting. + $defaultValue = $cspForm->isCspEnabled() ? '0' : '1'; + $cspForm->populate([ + 'security__csp_enable_modules' => $config->get('security', 'csp_enable_modules', $defaultValue), + 'security__csp_enable_dashboards' => $config->get('security', 'csp_enable_dashboards', $defaultValue), + 'security__csp_enable_navigation' => $config->get('security', 'csp_enable_navigation', $defaultValue), + ]); + + $cspForm->on(ContractForm::ON_SUBMIT, function (CspConfigForm $form) { + if ($form->hasConfigChanged()) { + $this->getResponse()->setReloadWindow(true); + } + }); + $cspForm->handleRequest(ServerRequest::fromGlobals()); + $this->view->cspForm = $cspForm; + $this->createApplicationTabs()->activate('security'); + } + /** * Display the list of all modules */ diff --git a/application/forms/Config/General/ApplicationConfigForm.php b/application/forms/Config/General/ApplicationConfigForm.php index c8fcd7ae37..98a476512b 100644 --- a/application/forms/Config/General/ApplicationConfigForm.php +++ b/application/forms/Config/General/ApplicationConfigForm.php @@ -57,18 +57,6 @@ public function createElements(array $formData) ] ); - $this->addElement( - 'checkbox', - 'security_use_strict_csp', - [ - 'label' => $this->translate('Enable strict content security policy'), - 'description' => $this->translate( - 'Set whether to use strict content security policy (CSP).' - . ' This setting helps to protect from cross-site scripting (XSS).' - ) - ] - ); - $this->addElement( 'text', 'global_module_path', diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php new file mode 100644 index 0000000000..30c4dd9605 --- /dev/null +++ b/application/forms/Config/Security/CspConfigForm.php @@ -0,0 +1,629 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Forms\Config\Security; + +use Exception; +use Icinga\Application\Config; +use Icinga\Security\Csp\AttributedCsp; +use Icinga\Security\Csp\Loader\DashboardCspLoader; +use Icinga\Security\Csp\Loader\ModuleCspLoader; +use Icinga\Security\Csp\Loader\NavigationCspLoader; +use Icinga\Security\Csp\Reason\DashboardCspReason; +use Icinga\Security\Csp\Reason\ModuleCspReason; +use Icinga\Security\Csp\Reason\NavigationCspReason; +use Icinga\Security\Csp\Reason\StaticCspReason; +use Icinga\Util\Csp; +use Icinga\Web\Form\ConfigForm; +use Icinga\Web\Session; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; +use ipl\Html\Table; +use ipl\Html\Text; +use ipl\Validator\CallbackValidator; +use ipl\Web\Common\CalloutType; +use ipl\Web\Common\Csp as CspInstance; +use ipl\Web\Common\CsrfCounterMeasure; +use ipl\Web\Common\FormUid; +use ipl\Web\Compat\DisplayFormElement; +use ipl\Web\Widget\Callout; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; + +/** + * Configuration form for CSP + * + * This form is used to configure the CSP-Header. It is used to enable or + * disable CSP, configure the allowed sources for automatic generation or to + * specify a custom CSP-Header. + */ +class CspConfigForm extends ConfigForm +{ + use FormUid; + use CsrfCounterMeasure; + + /** + * @var string[] List of all keywords that are considered secure. {@link https://centralcsp.com/docs/csp-keywords} + */ + protected const SECURE_KEYWORDS = [ + "'self'", + "'none'", + "'strict-dynamic'", + "'report-sample'", + "'report-sha256'", + "'report-sha384'", + "'report-sha512'", + ]; + + /** + * @var string[] List of all keywords that should display a warning. + * {@link https://centralcsp.com/docs/csp-keywords} + */ + protected const WARNING_KEYWORDS = [ + "'unsafe-inline'", + "'unsafe-eval'", + "'unsafe-hashes'", + ]; + + /** + * @var string[] List of all schemes that are considered secure. + * {@link https://centralcsp.com/docs/csp-scheme-source} + */ + protected const SECURE_SCHEMES = [ + 'https', + 'wss', + ]; + + /** + * @var string[] List of all schemes that should display a warning. + * {@link https://centralcsp.com/docs/csp-scheme-source} + */ + protected const WARNING_SCHEMES = [ + 'http', + 'ws', + 'blob', + ]; + + /** @var string[] List of directives where data is considered critical */ + protected const CRITICAL_DATA_DIRECTIVES = [ + 'default-src', + 'script-src', + 'object-src', + 'frame-src', + ]; + + /** @var string[] List of directives where data is considered a warning */ + protected const WARNING_DATA_DIRECTIVES = [ + 'style-src', + 'worker-src', + 'child-src', + 'base-uri', + ]; + + /** + * @var int The number of rows for the custom CSP textarea + */ + protected const TEXTAREA_ROWS = 8; + + /** + * @var bool Whether the form contents changed the underlying configuration + */ + protected bool $changed = false; + + /** + * @param Config $config The config object + */ + public function __construct(Config $config) + { + parent::__construct($config); + $this->setAttribute('name', 'csp_config'); + $this->getAttributes()->add('class', 'csp-config-form'); + $this->applyDefaultElementDecorators(); + } + + protected function assemble(): void + { + Csp::createNonce(); + + $this->addElement($this->createUidElement()); + + $this->addCsrfCounterMeasure(Session::getSession()->getId()); + + $this->addElement('checkbox', 'security__use_strict_csp', [ + 'label' => $this->translate('Send CSP header'), + 'description' => $this->translate( + 'Use strict content security policy (CSP).' + . ' This setting helps to protect from cross-site scripting (XSS).', + ), + 'class' => 'autosubmit', + 'checkedValue' => '1', + 'uncheckedValue' => '0', + ]); + + $useCustomCsp = $this->getPopulatedValue( + 'security__use_custom_csp', + $this->config->get('security', 'use_custom_csp'), + ) === '1'; + + $formHintClassList = ['csp-form-hint']; + if ($useCustomCsp) { + $formHintClassList[] = 'csp-disabled'; + } + + $this->addHtml((new DisplayFormElement(HtmlElement::create( + 'p', + ['class' => $formHintClassList], + $this->translate( + 'Enabling CSP will block some requests and may prevent some functionality from working as expected.', + ), + )))->addAttributes(Attributes::create(['class' => 'csp-control-group']))); + + if (! $this->isCspEnabled()) { + $this->addElement('hidden', 'security__use_custom_csp'); + $this->addElement('hidden', 'security__custom_csp'); + $this->addElement('hidden', 'security__csp_enable_modules'); + $this->addElement('hidden', 'security__csp_enable_dashboards'); + $this->addElement('hidden', 'security__csp_enable_navigation'); + } else { + $this->addHtml((new DisplayFormElement( + HtmlElement::create('div', null, [ + HtmlElement::create('h3', ['class' => $formHintClassList], $this->translate('Allowed Sources')), + HtmlElement::create('p', ['class' => $formHintClassList], $this->translate( + 'Sources that are used in the generation of the CSP header.', + )), + HtmlElement::create('h4', ['class' => $formHintClassList], $this->translate('System')), + ]), + ))->addAttributes(Attributes::create(['class' => 'csp-control-group']))); + + $this->addDirectiveContentElement( + [Csp::getSystemCsp()], + [$this->translate('Directive'), $this->translate('Value')], + function (StaticCspReason $reason, string $directive, string $expression) { + return Table::tr([Table::td($directive), $this->buildExpression($directive, $expression)]); + }, + ! $useCustomCsp, + $this->translate('No system policies defined.'), + ); + + $this->addDirectiveCheckboxElement( + $this->translate('Enable Modules'), + $this->translate( + 'Should module defined CSP directives be enabled?' + . ' Modules can define or change csp directives at any point.', + ), + 'security__csp_enable_modules', + ! $useCustomCsp, + ); + + $this->addDirectiveContentElement( + (new ModuleCspLoader())->loadForAllUsers(), + [$this->translate('Module'), $this->translate('Directive'), $this->translate('Value')], + function (ModuleCspReason $reason, string $directive, string $expression) { + return Table::tr([ + Table::td($reason->module), + Table::td($directive), + $this->buildExpression($directive, $expression), + ]); + }, + ! $useCustomCsp && $this->getValue('security__csp_enable_modules') === '1', + $this->translate('No module policies defined.'), + ); + + $this->addDirectiveCheckboxElement( + $this->translate('Enable Dashboards'), + $this->translate( + 'Enable user defined dashboards. This table contains all dashboards for all users. The actual' + . ' header that is sent to the user will only contain the subset of directives that actually' + . ' matters to them.', + ), + 'security__csp_enable_dashboards', + ! $useCustomCsp, + ); + + $this->addDirectiveContentElement( + (new DashboardCspLoader())->loadForAllUsers(), + [ + $this->translate('Dashboard'), + $this->translate('Dashlet'), + $this->translate('User'), + $this->translate('Directive'), + $this->translate('Value'), + ], + function (DashboardCspReason $reason, string $directive, string $expression) { + return Table::tr([ + Table::td($reason->pane->getName()), + Table::td($reason->dashlet->getName()), + Table::td($reason->dashboard->getUser()->getUsername()), + Table::td($directive), + $this->buildExpression($directive, $expression), + ]); + }, + ! $useCustomCsp && $this->getValue('security__csp_enable_dashboards') === '1', + $this->translate('No dashboard policies found.'), + ); + + $this->addDirectiveCheckboxElement( + $this->translate('Enable Navigation Items'), + $this->translate( + 'Enable user defined navigation items. This table contains all navigation items for' + . ' all users. The actual header that is sent to the user will only contain the subset of' + . ' directives that actually matters to them.', + ), + 'security__csp_enable_navigation', + ! $useCustomCsp, + ); + + $this->addDirectiveContentElement( + (new NavigationCspLoader())->loadForAllUsers(), + [ + $this->translate('Navigation'), + $this->translate('Parent'), + $this->translate('Name'), + $this->translate('User'), + $this->translate('Directive'), + $this->translate('Value'), + ], + function (NavigationCspReason $reason, string $directive, string $expression) { + $parentCell = $reason->parent === null + ? Table::td($this->translate('None'))->setAttribute('class', 'empty-state') + : Table::td($reason->parent); + + $sharedIcon = match ($reason->isShared) { + true => new Icon('share', [ + 'class' => 'shared-item', + 'title' => $this->translate('Shared item. Displayed user is owner.'), + ]), + false => null, + }; + + $userCell = $reason->username === null + ? Table::td([$sharedIcon, $this->translate('Unknown')])->setAttribute('class', 'empty-state') + : Table::td([$sharedIcon, $reason->username]); + + return Table::tr([ + Table::td($reason->typeConfiguration['label'] ?? $reason->type), + $parentCell, + Table::td($reason->name), + $userCell, + Table::td($directive), + $this->buildExpression($directive, $expression), + ]); + }, + ! $useCustomCsp && $this->getValue('security__csp_enable_navigation') === '1', + $this->translate('No navigation policies found.'), + ); + + $this->addElement( + 'checkbox', + 'security__use_custom_csp', + [ + 'label' => $this->translate('Enable Custom CSP'), + 'description' => $this->translate( + 'Specify whether to use a custom, user-provided string as the CSP header.', + ), + 'class' => 'autosubmit csp-form-content-aligned csp-label-header-h3 csp-form-header', + 'checkedValue' => '1', + 'uncheckedValue' => '0', + ], + ); + + if ($this->isCustomCspEnabled()) { + $this->add(new DisplayFormElement(new Callout( + CalloutType::Warning, + $this->translate( + 'Be aware that the custom CSP header completely overrides the automatically generated one.' + . ' This means that you are solely responsible for keeping the custom CSP header up to date' + . ' and secure.', + ), + $this->translate('Warning: Use at your own risk!'), + ))); + } + + $this->addElement('textarea', 'security__custom_csp', [ + 'label' => '', + 'description' => $this->translate( + 'Set a custom CSP header. This completely overrides the automatically generated one.' + . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.', + ), + 'rows' => static::TEXTAREA_ROWS, + 'disabled' => ! $this->isCustomCspEnabled(), + 'required' => $this->isCustomCspEnabled(), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator) { + if (empty($value) || ! $this->isCustomCspEnabled()) { + return true; + } + + try { + CspInstance::fromString(str_replace('{style_nonce}', "'nonce-validation'", $value)); + } catch (Exception $e) { + $validator->addMessage($e->getMessage()); + + return false; + } + + return true; + }), + ], + ]); + } + } + + protected function onSuccess(): void + { + $section = $this->config->getSection('security'); + $beforeSection = clone $section; + + parent::onSuccess(); + + $a = iterator_to_array($section); + $b = iterator_to_array($beforeSection); + $this->changed = ! empty(array_diff_assoc($a, $b)) || ! empty(array_diff_assoc($b, $a)); + } + + /** + * Has the CSP configuration changed since the last time the form was submitted? + * + * @return bool + */ + public function hasConfigChanged(): bool + { + return $this->changed; + } + + /** + * Would CSP be enabled if the form contents where submitted? + * + * @return bool + */ + public function isCspEnabled(): bool + { + return $this->getValue('security__use_strict_csp', $this->config->get('security', 'use_strict_csp')) === '1'; + } + + /** + * Would custom CSP be enabled if the form contents where submitted? + * + * @return bool + */ + public function isCustomCspEnabled(): bool + { + return $this->getValue('security__use_custom_csp', $this->config->get('security', 'use_custom_csp')) === '1'; + } + + /** + * Add a checkbox that enables or disables a group of CSP directives + * + * @param string $label The label of the checkbox + * @param string $description The description of the checkbox + * @param string $field The name of the checkbox field + * @param bool $enabled Whether the checkbox should be checked and enabled + * + * @return void + */ + protected function addDirectiveCheckboxElement( + string $label, + string $description, + string $field, + bool $enabled, + ): void { + $classList = [ + 'autosubmit', + 'csp-form-content-aligned', + 'csp-label-header-h4', + ]; + + if (! $enabled) { + $classList[] = 'csp-disabled'; + } + + $this->addElement('checkbox', $field, [ + 'label' => $label, + 'description' => $description, + 'class' => $classList, + 'checkedValue' => '1', + 'uncheckedValue' => '0', + 'disabled' => ! $enabled, + 'value' => $this->getPopulatedValue($field), + ]); + } + + /** + * Add a table that displays the content of the given CSP directives. + * + * @param AttributedCsp[] $attributedCsps The list of CSPs along with their reasons + * @param string[] $header The header of the table + * @param callable $rowBuilder A function that builds a row for the table + * @param bool $enabled Whether the content should be enabled + * @param string $emptyText The text to display if there are no policies + * + * @return void + */ + protected function addDirectiveContentElement( + array $attributedCsps, + array $header, + callable $rowBuilder, + bool $enabled, + string $emptyText, + ): void { + $rows = []; + foreach ($attributedCsps as $attributed) { + foreach ($attributed->csp->getDirectives() as $directive => $expressions) { + foreach ($expressions as $expression) { + $rows[] = $rowBuilder($attributed->reason, $directive, $expression); + } + } + } + + if (count($rows) === 0) { + $this->addHtml( + (new DisplayFormElement(HtmlElement::create('p', ['class' => 'csp-form-hint'], $emptyText))) + ->addAttributes(Attributes::create(['class' => 'csp-control-group'])) + ); + + return; + } + + $classList = ['csp-config-table']; + if (! $enabled) { + $classList[] = 'csp-disabled'; + } + + $table = new Table(); + $table->addAttributes(Attributes::create(['class' => $classList])); + $headerRow = Table::tr(); + foreach ($header as $h) { + $headerRow->add(Table::th($h)); + } + + $table->add($headerRow); + foreach ($rows as $row) { + $table->add($row); + } + + $this->add((new DisplayFormElement( + HtmlElement::create('div', ['class' => 'collapsible', 'data-visible-height' => 100], $table), + ))->addAttributes(Attributes::create(['class' => 'csp-control-group']))); + } + + /** + * Categorize the expression keywords into secure, warning, and unknown + * + * @param string $expression The expression to categorize + * + * @return string|null + */ + protected function getKeywordType(string $expression): ?string + { + if (in_array(strtolower($expression), static::SECURE_KEYWORDS)) { + return 'secure'; + } + + if (in_array(strtolower($expression), static::WARNING_KEYWORDS)) { + return 'warning'; + } + + return null; + } + + /** + * Categorize the expression schemes into secure, warning, and unknown + * + * @param string $directive The directive that the expression belongs to + * @param string $expression The expression to categorize + * + * @return string|null + */ + protected function getSchemeType(string $directive, string $expression): ?string + { + if (! str_ends_with($expression, ':') || str_contains($expression, ' ')) { + return null; + } + + $scheme = strtolower(substr($expression, 0, -1)); + + if (in_array($scheme, static::SECURE_SCHEMES)) { + return 'secure'; + } + + if (in_array($scheme, static::WARNING_SCHEMES)) { + return 'warning'; + } + + if ($scheme === 'data') { + if (in_array($directive, static::CRITICAL_DATA_DIRECTIVES)) { + return 'critical'; + } + + if (in_array($directive, static::WARNING_DATA_DIRECTIVES)) { + return 'warning'; + } + } + + return 'unknown'; + } + + /** + * Whether the given expression is a nonce + * + * @param string $expression The expression to check + * + * @return bool + */ + protected function isNonce(string $expression): bool + { + return (str_starts_with($expression, "'nonce-") && str_ends_with($expression, "'")); + } + + /** + * Build an HTML element that represents the given expression. + * + * @param string $directive The directive that the expression belongs to + * @param string $expression The expression to build + * + * @return BaseHtmlElement + */ + protected function buildExpression(string $directive, string $expression): BaseHtmlElement + { + if ($expression === '*') { + $result = HtmlElement::create('span', ['class' => 'csp-wildcard'], [ + $expression, + new Icon('warning', [ + 'class' => 'csp-expression-info', + 'title' => $this->translate( + 'This is a wildcard expression. It allows everything and should therefore be avoided.', + ), + ]), + ]); + } elseif (($keyword = $this->getKeywordType($expression)) !== null) { + $icon = match ($keyword) { + 'warning' => new Icon('warning', [ + 'class' => 'csp-expression-info', + 'title' => $this->translate('This is a potentially unsafe keyword.'), + ]), + default => null, + }; + $result = HtmlElement::create( + 'span', + ['class' => ['csp-keyword', 'csp-' . $keyword]], + [$expression, $icon], + ); + } elseif (($scheme = $this->getSchemeType($directive, $expression)) !== null) { + $icon = match ($scheme) { + 'warning' => new Icon('warning', [ + 'class' => 'csp-expression-info', + 'title' => $this->translate('This is a potentially unsafe scheme.'), + ]), + 'critical' => new Icon('warning', [ + 'class' => 'csp-expression-info', + 'title' => $this->translate('This is a critical scheme and should not be used.'), + ]), + default => null, + }; + $result = HtmlElement::create( + 'span', + ['class' => ['csp-scheme', 'csp-' . $scheme]], + [$expression, $icon], + ); + } elseif ($this->isNonce($expression)) { + $result = HtmlElement::create('span', ['class' => 'csp-nonce'], [ + $expression, + new Icon('info-circle', [ + 'class' => 'csp-expression-info', + 'title' => $this->translate( + 'This is an automatically generated nonce. Its value is unique per request.', + ), + ]), + ]); + } elseif (filter_var($expression, FILTER_VALIDATE_URL) !== false) { + $result = new Link($expression, $expression, ['target' => '_blank', 'rel' => 'noopener noreferrer']); + } else { + $result = new Text($expression); + } + + return Table::td($result, ['class' => 'csp-expressions']); + } +} diff --git a/application/forms/Dashboard/DashletForm.php b/application/forms/Dashboard/DashletForm.php index 5af52d113a..f0f3c5b06c 100644 --- a/application/forms/Dashboard/DashletForm.php +++ b/application/forms/Dashboard/DashletForm.php @@ -5,12 +5,16 @@ namespace Icinga\Forms\Dashboard; +use Icinga\Util\Csp; use Icinga\Web\Form; use Icinga\Web\Form\Validator\InternalUrlValidator; use Icinga\Web\Form\Validator\UrlValidator; use Icinga\Web\Url; use Icinga\Web\Widget\Dashboard; use Icinga\Web\Widget\Dashboard\Dashlet; +use ipl\Web\Common\CalloutType; +use ipl\Web\Compat\DisplayFormElement; +use ipl\Web\Widget\Callout; /** * Form to add an url a dashboard pane @@ -75,6 +79,24 @@ public function createElements(array $formData) ] ); + if (Csp::isEnabled() && ! Csp::isDashboardEnabled()) { + $this->addElement( + 'note', + 'csp_warning', + [ + 'decorators' => ['ViewHelper'], + 'value' => (new DisplayFormElement(new Callout( + CalloutType::Info, + $this->translate( + 'Any external url is not guaranteed to work as expected. ' + . 'Please make sure to check the Content-Security-Policy configuration.', + ), + $this->translate('Dashboards are not enabled in the CSP configuration'), + )))->render(), + ] + ); + } + $this->addElement( 'textarea', 'url', diff --git a/application/forms/Navigation/NavigationItemForm.php b/application/forms/Navigation/NavigationItemForm.php index a40771c732..44a5da4a1e 100644 --- a/application/forms/Navigation/NavigationItemForm.php +++ b/application/forms/Navigation/NavigationItemForm.php @@ -5,8 +5,12 @@ namespace Icinga\Forms\Navigation; +use Icinga\Util\Csp; use Icinga\Web\Form; use Icinga\Web\Url; +use ipl\Web\Common\CalloutType; +use ipl\Web\Compat\DisplayFormElement; +use ipl\Web\Widget\Callout; class NavigationItemForm extends Form { @@ -48,6 +52,24 @@ public function createElements(array $formData) ] ); + if (Csp::isEnabled() && ! Csp::isNavigationEnabled()) { + $this->addElement( + 'note', + 'csp_warning', + [ + 'decorators' => ['ViewHelper'], + 'value' => (new DisplayFormElement(new Callout( + CalloutType::Info, + $this->translate( + 'Any external url is not guaranteed to work as expected. ' + . 'Please make sure to check the Content-Security-Policy configuration.', + ), + $this->translate('Navigation items are not enabled in the CSP configuration'), + )))->render(), + ] + ); + } + $this->addElement( 'textarea', 'url', diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php index 804b93a98c..44577c81fd 100644 --- a/application/forms/Security/RoleForm.php +++ b/application/forms/Security/RoleForm.php @@ -548,6 +548,9 @@ public static function collectProvidedPrivileges() 'config/general' => [ 'description' => t('Allow to adjust the general configuration') ], + 'config/security' => [ + 'description' => t('Allow to adjust the security configuration') + ], 'config/modules' => [ 'description' => t('Allow to enable/disable and configure modules') ], diff --git a/application/views/scripts/config/security.phtml b/application/views/scripts/config/security.phtml new file mode 100644 index 0000000000..24208eaf85 --- /dev/null +++ b/application/views/scripts/config/security.phtml @@ -0,0 +1,7 @@ +
+ +
+
+

translate('Content Security Policy') ?>

+ +
diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md index 89160bca0b..4aaf7b3a03 100644 --- a/doc/03-Configuration.md +++ b/doc/03-Configuration.md @@ -41,19 +41,6 @@ config_resource = "icingaweb_db" module_path = "/usr/share/icingaweb2/modules" ``` -### Security Configuration - -| Option | Description | -|------------------|---------------------------------------------------------------------------------------------------------------------------------------| -| use\_strict\_csp | **Optional.** Set this to `1` to enable strict [Content Security Policy](20-Advanced-Topics.md#advanced-topics-csp). Defaults to `0`. | - -Example: - -``` -[security] -use_strict_csp = "1" -``` - ### Logging Configuration Option | Description @@ -87,3 +74,32 @@ Example: disabled = "1" default = "high-contrast" ``` + +## Security Configuration + +Navigate into **Configuration > Application > Security**. + +This configuration is stored in the `config.ini` file in `/etc/icingaweb2`. + +### Content Security Policy Configuration + +| Option | Description | +|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| +| use\_strict\_csp | **Optional.** Set this to `1` to enable strict [Content Security Policy](20-Advanced-Topics.md#advanced-topics-csp). Defaults to `0`. | +| use\_custom\_csp | **Optional.** Set this to `1` to enable the use of the user defined Content Security Policy. Defaults to `0`. | +| custom\_csp | **Optional.** Specifies the user defined Content Security Policy. Overrides the automatically generated one. Only used if `use_custom_csp` is set to `1`. | +| csp\_enable\_modules | **Optional.** Specifies if modules should be included in the generated Content Security Policy. Defaults to `1`. | +| csp\_enable\_dashboards | **Optional.** Specifies if dashboards should be included in the generated Content Security Policy. Defaults to `1`. | +| csp\_enable\_navigation | **Optional.** Specifies if navigation menu items should be included in the generated Content Security Policy. Defaults to `1`. | + +Example: + +``` +[security] +use_strict_csp = "1" +use_custom_csp = "0" +custom_csp = "frame-src https://example.com" +csp_enable_modules = "1" +csp_enable_dashboards = "1" +csp_enable_navigation = "1" +``` diff --git a/doc/20-Advanced-Topics.md b/doc/20-Advanced-Topics.md index a144a5be01..b24c91c0bf 100644 --- a/doc/20-Advanced-Topics.md +++ b/doc/20-Advanced-Topics.md @@ -117,24 +117,35 @@ systemctl reload httpd Elevate your security standards to an even higher level by enabling the [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) for Icinga Web. Enabling strict CSP can prevent your Icinga Web environment from becoming a potential target of [Cross-Site Scripting (XSS)](https://developer.mozilla.org/en-US/docs/Glossary/Cross-site_scripting) -and data injection attacks. After enabling this feature Icinga Web defines all the required CSP headers. Subsequently, +and data injection attacks. After enabling this feature, Icinga Web defines all the required CSP headers. Subsequently, only content coming from Icinga Web's own origin is accepted, inline JS is prohibited, and inline CSS is accepted only if it contains the nonce set in the response header. We decided against enabling this by default as we cannot guarantee that all the modules out there will function correctly. Therefore, you have to manually enable this policy explicitly and accept the risks that this might break some of -the Icinga Web modules. Icinga Web and all it's components listed below, on the other hand, fully support strict CSP. If +the Icinga Web modules. Icinga Web and all its components listed below, on the other hand, fully support strict CSP. If that's not the case, please submit an issue on GitHub in the respective repositories. -To enable the strict content security policy navigate to **Configuration > Application** and toggle "Enable strict content security policy", -or set the `use_strict_csp` in the `config.ini`. +To enable the strict content security policy, navigate to **Configuration > Application > Security** and toggle +"Send CSP header", or set `use_strict_csp` in the `config.ini`. -``` -vim /etc/icingaweb2/config.ini +Icinga does its best to support user-defined content like navigation items and dashboard dashlets. If that behavior is +not desired, you can disable both by disabling the corresponding feature in the **Security page** at +**Configuration > Application > Security** or by setting `csp_enable_navigation` or `csp_enable_dashboards` in the +`config.ini`. Note that while you can see all navigation items and dashboards, the actual CSP is generated per user +and does not include the full set of directives shown. -[security] -use_strict_csp = "1" -``` +If it is necessary to add extra entries to the CSP header, you can do so by using the `CspHook` hook, +read more about it [here](60-Hooks.md#hooks-csp). This is the preferred way to extend the CSP header +because it is an additive and modular approach. + +Alternatively you can define your own CSP header by setting the `custom_csp` in the `config.ini` or by configuring the +`Custom CSP` section at **Configuration > Application > Security** which will completely overwrite the generated +CSP header. +Therefore, you are responsible for ensuring that the CSP header is valid, does not contain insecure directives, +is kept up to date with updates or changes to the icingaweb application or its components, and works for every user. +When creating your own CSP header, you can use the placeholder `{style_nonce}` in place of the +automatically generated nonce. This will be replaced with the actual nonce when a user loads icingaweb. Here is a list of all Icinga Web components that are capable of strict CSP. @@ -155,6 +166,17 @@ Here is a list of all Icinga Web components that are capable of strict CSP. | Icinga Web AWS Integration | [v1.1.0](https://github.com/Icinga/icingaweb2-module-aws/releases/tag/v1.1.0) | | Icinga Web vSphere Integration | [v1.8.0](https://github.com/Icinga/icingaweb2-module-vspheredb/releases/tag/v1.8.0) | +``` +vim /etc/icingaweb2/config.ini + +[security] +use_strict_csp = "1" +csp_enable_modules = "1" +csp_enable_dashboards = "1" +csp_enable_navigation = "1" +use_custom_csp = "0" +custom_csp = "" +``` ## Advanced Authentication Tips @@ -318,7 +340,7 @@ which may help you already: If you are automating the installation of Icinga Web 2, you may want to skip the wizard and do things yourself. These are the steps you'd need to take assuming you are using MySQL/MariaDB. If you are using PostgreSQL please adapt -accordingly. Note you need to have successfully completed the Icinga 2 installation, installed the Icinga Web 2 packages +accordingly. Note you need to have successfully completed the Icinga 2 installation, installed the Icinga Web 2 packages, and all the other steps described above first. 1. Install PHP dependencies: `php`, `php-intl`, `php-imagick`, `php-gd`, `php-mysql`, `php-curl`, `php-mbstring` used diff --git a/doc/60-Hooks.md b/doc/60-Hooks.md index 2dc645d992..fde6881f75 100644 --- a/doc/60-Hooks.md +++ b/doc/60-Hooks.md @@ -47,3 +47,45 @@ class ConfigFormEvents extends ConfigFormEventsHook } } ``` + +## CspHook + +The `CspHook` allows developers to add custom CSP directives to the Icinga Web 2 frontend. +It provides the methods `getCspForUser(User)` and `getCspForAllUsers()` which should return +a `Csp` instance with the directives the module wants to add. The difference between the two +methods is that `getCspForUser()` is called for a specific user instance and should return +the CSP directives that specific user requires. While `getCspForAllUsers()` is called for +all users and should return the CSP directives that any one user requires. The directives are +combined additively with the default directives, icingaweb2 generated ones and other +module-defined directives. + +Hook example: + +```php +namespace Icinga\Module\Acme\ProvidedHook; + +use Icinga\Application\Hook\CspHook; +use ipl\Web\Common\Csp as CspInstance; +use Icinga\User; + +class Csp extends CspHook +{ + public function getCspForAllUsers(): CspInstance + { + $csp = new CspInstance(); + $csp->add('img-src', ['cdn.example.com', 'usercontent.example.com']); + $csp->add('style-src', 'cdn.example.com'); + + // ... + + return $csp; + } + + public function getCspForUser(User $user) : CspInstance + { + // ... + + return $csp; + } +} +``` diff --git a/library/Icinga/Application/Hook/CspHook.php b/library/Icinga/Application/Hook/CspHook.php new file mode 100644 index 0000000000..ef2155ac4a --- /dev/null +++ b/library/Icinga/Application/Hook/CspHook.php @@ -0,0 +1,58 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Application\Hook; + +use Icinga\Application\Hook; +use Icinga\User; +use ipl\Web\Common\Csp; + +/** + * Allow modules to provide custom Content-Security-Policy policies. + * This hook is only used if the CSP header is enabled. + */ +abstract class CspHook +{ + use HookEssentials; + + final protected static function getHookName(): string + { + return 'Csp'; + } + + /** + * Always run this hook + * + * Handling of CSP is a system-level feature, so this hook should run for every + * request, regardless of a user's permissions. + * + * @return bool + */ + protected static function isAlwaysRun(): bool + { + return true; + } + + /** + * Get the CSP directives for a module + * + * @param User $user The user to generate the CSP for + * + * @return Csp A CSP instance with the required policies, this instance will + * be merged with all other requested directives. + */ + abstract public function getCspForUser(User $user): Csp; + + /** + * Get the CSP directives for a module for all users + * + * It should contain all required policies that are required for any user, + * not only directives that are non user specific. + * + * @return Csp A CSP instance with the required policies, this instance will + * be merged with all other requested directives. + */ + abstract public function getCspForAllUsers(): Csp; +} diff --git a/library/Icinga/Security/Csp/AttributedCsp.php b/library/Icinga/Security/Csp/AttributedCsp.php new file mode 100644 index 0000000000..a6c37e1259 --- /dev/null +++ b/library/Icinga/Security/Csp/AttributedCsp.php @@ -0,0 +1,25 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp; + +use Icinga\Security\Csp\Reason\CspReason; +use ipl\Web\Common\Csp; + +/** + * A CSP directive attributed to a specific source via a {@see CspReason} + */ +readonly class AttributedCsp +{ + /** + * @param Csp $csp The CSP directive + * @param CspReason $reason The reason for the CSP directive to exist + */ + public function __construct( + public Csp $csp, + public CspReason $reason, + ) { + } +} diff --git a/library/Icinga/Security/Csp/Loader/ArrayCspLoader.php b/library/Icinga/Security/Csp/Loader/ArrayCspLoader.php new file mode 100644 index 0000000000..b54986352e --- /dev/null +++ b/library/Icinga/Security/Csp/Loader/ArrayCspLoader.php @@ -0,0 +1,54 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp\Loader; + +use Icinga\Security\Csp\AttributedCsp; +use Icinga\Security\Csp\Reason\StaticCspReason; +use Icinga\User; +use ipl\Web\Common\Csp; + +/** + * Loads CSP directives from an array. + * Useful for testing or providing user-defined CSP configuration. + */ +class ArrayCspLoader implements CspLoader +{ + /** + * @param string $name The name to display for CSP reason + * @param array $directives The CSP directives to load. Each key is a + * directive name, and each value is an array of values for that directive. + */ + public function __construct( + protected string $name, + protected array $directives, + ) { + } + + /** + * Load the CSP directives for a specific user + * + * Since the array loader loads the CSP directives from the given array of directives, + * this method just returns the same result for all users {@see loadForAllUsers()} + * + * @param User $user + * + * @return AttributedCsp[] + */ + public function loadForUser(User $user): array + { + return $this->loadForAllUsers(); + } + + public function loadForAllUsers(): array + { + $csp = new Csp(); + foreach ($this->directives as $directive => $values) { + $csp->add($directive, $values); + } + + return [new AttributedCsp($csp, new StaticCspReason($this->name))]; + } +} diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php new file mode 100644 index 0000000000..120565f22e --- /dev/null +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -0,0 +1,32 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp\Loader; + +use Icinga\Security\Csp\AttributedCsp; +use Icinga\User; + +/** + * Interface for CSP loaders. + * A loader is responsible for loading CSP directives from a specific source. + */ +interface CspLoader +{ + /** + * Load the CSP directives from the source for all users + * + * @return AttributedCsp[] + */ + public function loadForAllUsers(): array; + + /** + * Load the CSP directives for a specific user + * + * @param User $user the user to load the CSP directives for + * + * @return AttributedCsp[] + */ + public function loadForUser(User $user): array; +} diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php new file mode 100644 index 0000000000..bbd021891b --- /dev/null +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -0,0 +1,84 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp\Loader; + +use DirectoryIterator; +use Icinga\Application\Config; +use Icinga\Security\Csp\AttributedCsp; +use Icinga\Security\Csp\Reason\DashboardCspReason; +use Icinga\User; +use Icinga\Web\Url; +use Icinga\Web\Widget\Dashboard; +use ipl\Web\Common\Csp; + +/** + * This loader is responsible for loading CSP directives for external URLs in dashboard panes. + * It iterates through all dashboard panes and checks if any dashlets have an external URL. + * If an external URL is found, it adds a CSP directive for the URL's host and port. + * The CSP directive allows the iframe to be embedded on the page. + */ +class DashboardCspLoader implements CspLoader +{ + public function loadForUser(User $user): array + { + $dashboard = new Dashboard(); + $dashboard->setUser($user); + $dashboard->load(); + + $result = []; + + /** @var Dashboard\Pane $pane */ + foreach ($dashboard->getPanes() as $pane) { + /** @var Dashboard\Dashlet $dashlet */ + foreach ($pane->getDashlets() as $dashlet) { + $url = $dashlet->getUrl(); + if ($url === null) { + continue; + } + + $urlString = $url->isExternal() + ? $url->getAbsoluteUrl() + : $url->getParam('url'); + + if ($urlString === null || filter_var($urlString, FILTER_VALIDATE_URL) === false) { + continue; + } + + $absoluteUrl = Url::fromPath($urlString); + $cspUrl = $absoluteUrl->getScheme() . '://' . $absoluteUrl->getHost(); + if (($port = $absoluteUrl->getPort()) !== null) { + $cspUrl .= ':' . $port; + } + + $csp = new Csp(); + $csp->add('frame-src', $cspUrl); + $result[] = new AttributedCsp($csp, new DashboardCspReason($dashboard, $pane, $dashlet)); + } + } + + return $result; + } + + public function loadForAllUsers(): array + { + $result = []; + $dashboardsDir = Config::resolvePath('dashboards'); + if (! is_dir($dashboardsDir)) { + return $result; + } + + foreach (new DirectoryIterator($dashboardsDir) as $dir) { + if ($dir->isDot() || ! $dir->isDir()) { + continue; + } + + $user = new User($dir->getFilename()); + $result = array_merge($result, $this->loadForUser($user)); + } + + return $result; + } +} diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php new file mode 100644 index 0000000000..288db396cd --- /dev/null +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -0,0 +1,73 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp\Loader; + +use Icinga\Application\ClassLoader; +use Icinga\Application\Hook\CspHook; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +use Icinga\Security\Csp\AttributedCsp; +use Icinga\Security\Csp\Reason\ModuleCspReason; +use RuntimeException; +use Icinga\User; +use Throwable; + +/** + * Loads CSP directives from modules. Modules can implement the {@see CspHook} + * interface to provide custom CSP directives. The hook is called for each + * request, allowing modules to dynamically add or modify CSP policies. + */ +class ModuleCspLoader implements CspLoader +{ + public function loadForAllUsers(): array + { + return $this->loadWith(fn($hook) => $hook->getCspForAllUsers()); + } + + public function loadForUser(User $user): array + { + return $this->loadWith(fn($hook) => $hook->getCspForUser($user)); + } + + /** + * Load CSP directives from modules using the given fetch function + * + * @param callable $fetch A function that takes a CspHook instance and returns a Csp + * + * @return AttributedCsp[] + */ + protected function loadWith(callable $fetch): array + { + $result = []; + + foreach (CspHook::all() as $hook) { + try { + $csp = $fetch($hook); + if ($csp->isEmpty()) { + continue; + } + + $moduleName = ClassLoader::extractModuleName(get_class($hook)); + if ($csp->hasDirective('default-src')) { + throw new RuntimeException( + sprintf("Setting 'default-src' is not allowed. Module: %s", $moduleName), + ); + } + + $result[] = new AttributedCsp($csp, new ModuleCspReason($moduleName)); + } catch (Throwable $e) { + Logger::warning( + "Failed to invoke CSP hook %s: %s\n%s", + get_class($hook), + $e->getMessage(), + IcingaException::getConfidentialTraceAsString($e), + ); + } + } + + return $result; + } +} diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php new file mode 100644 index 0000000000..6b463fda09 --- /dev/null +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -0,0 +1,230 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp\Loader; + +use DirectoryIterator; +use Icinga\Application\Config; +use Icinga\Data\ConfigObject; +use Icinga\Security\Csp\AttributedCsp; +use Icinga\Security\Csp\Reason\NavigationCspReason; +use Icinga\User; +use Icinga\Web\Navigation\Navigation; +use ipl\Web\Common\Csp; +use ipl\Web\Url; + +/** + * Loads CSP directives for navigation items that have an external URL. + * The CSP directive allows the iframe to be embedded on the page. + */ +class NavigationCspLoader implements CspLoader +{ + /** + * Load CSP directives for user navigation items that have an external URL + * + * @param string $type The navigation type + * @param array $typeConfig The navigation type configuration + * @param User $user User to check access for + * + * @return AttributedCsp[] + */ + protected function loadUserConfig(string $type, array $typeConfig, User $user): array + { + $config = Config::navigation($type, $user->getUsername()); + if ($config->isEmpty()) { + return []; + } + + $result = []; + foreach ($config as $sectionName => $section) { + $parsed = $this->parseNavigationSection( + $type, + $typeConfig, + $sectionName, + $section, + false, + $user->getUsername() + ); + if ($parsed !== null) { + $result[] = $parsed; + } + } + + return $result; + } + + /** + * Load CSP directives for shared navigation items that have an external URL + * + * @param string $type The navigation type + * @param array $typeConfig The navigation type configuration + * @param ?User $user User to check access for. If null, all shared + * navigation items are loaded. + * + * @return array + */ + protected function loadSharedConfig(string $type, array $typeConfig, ?User $user): array + { + $config = Config::navigation($type); + if ($config->isEmpty()) { + return []; + } + + $result = []; + foreach ($config as $sectionName => $section) { + if ($user !== null && ! $this->hasAccessToSharedNavigationItem($section, $config, $user)) { + continue; + } + + $parsed = $this->parseNavigationSection( + $type, + $typeConfig, + $sectionName, + $section, + true, + $user?->getUsername() + ); + if ($parsed !== null) { + $result[] = $parsed; + } + } + + return $result; + } + + /** + * Parse a navigation section and return an AttributedCsp if the section is valid and should be loaded + * + * @param string $type First level navigation type + * @param array $typeConfig First level navigation type configuration + * @param string $sectionName The name of the ini section + * @param ConfigObject $section The navigation section + * @param bool $isShared Whether the section is shared + * @param ?string $username The username of the user, if the section is bound to a user + * + * @return ?AttributedCsp + */ + protected function parseNavigationSection( + string $type, + array $typeConfig, + string $sectionName, + ConfigObject $section, + bool $isShared, + ?string $username + ): ?AttributedCsp { + if ($section->isEmpty() + || $section->get('target') === '_blank' + || $section->get('url') === null + || filter_var($section['url'], FILTER_VALIDATE_URL) === false + ) { + return null; + } + + $owner = $section->get('owner'); + $url = Url::fromPath($section['url']); + $cspUrl = $url->getScheme() . '://' . $url->getHost(); + if (($port = $url->getPort()) !== null) { + $cspUrl .= ':' . $port; + } + + $parent = $section->get('parent'); + + $csp = new Csp(); + $csp->add('frame-src', $cspUrl); + return new AttributedCsp($csp, new NavigationCspReason( + $type, + $typeConfig, + $parent, + $sectionName, + $isShared, + $username ?? $owner, + )); + } + + public function loadForAllUsers(): array + { + $result = []; + $navigationTypes = Navigation::getItemTypeConfiguration(); + foreach ($navigationTypes as $type => $typeConfig) { + $result = array_merge($result, $this->loadSharedConfig($type, $typeConfig, null)); + $preferencesDir = Config::resolvePath('preferences'); + if (! is_dir($preferencesDir)) { + continue; + } + + foreach (new DirectoryIterator($preferencesDir) as $userDir) { + if ($userDir->isDot() || ! $userDir->isDir()) { + continue; + } + + $result = array_merge( + $result, + $this->loadUserConfig($type, $typeConfig, new User($userDir->getFilename())), + ); + } + } + + return $result; + } + + public function loadForUser(User $user): array + { + $result = []; + $navigationTypes = Navigation::getItemTypeConfiguration(); + foreach ($navigationTypes as $type => $typeConfig) { + $result = array_merge($result, $this->loadSharedConfig($type, $typeConfig, $user)); + $result = array_merge($result, $this->loadUserConfig($type, $typeConfig, $user)); + } + + return $result; + } + + /** + * Check whether the user has access to a shared navigation item + * + * Also handles inheritance of access restrictions. This method mimics the + * behavior of {@see \Icinga\Application\Web::hasAccessToSharedNavigationItem()}. + * + * @param ConfigObject $config The navigation item configuration + * @param Config $navConfig The navigation configuration + * @param User $user The user to check access for + * + * @return bool + */ + private function hasAccessToSharedNavigationItem(ConfigObject $config, Config $navConfig, User $user): bool + { + if (isset($config['owner']) && strtolower($config['owner']) === strtolower($user->getUsername())) { + return true; + } + + if (isset($config['parent']) && $navConfig->hasSection($config['parent'])) { + $parentConfig = $navConfig->getSection($config['parent']); + + return $this->hasAccessToSharedNavigationItem($parentConfig, $navConfig, $user); + } + + if (isset($config['users'])) { + $users = array_map(trim(...), explode(',', strtolower($config['users']))); + if (in_array('*', $users, true) || in_array(strtolower($user->getUsername()), $users, true)) { + return true; + } + } + + if (isset($config['groups'])) { + $groups = array_map(trim(...), explode(',', strtolower($config['groups']))); + if (in_array('*', $groups, true)) { + return true; + } + + $userGroups = array_map(strtolower(...), $user->getGroups()); + $matches = array_intersect($userGroups, $groups); + if (! empty($matches)) { + return true; + } + } + + return false; + } +} diff --git a/library/Icinga/Security/Csp/Reason/CspReason.php b/library/Icinga/Security/Csp/Reason/CspReason.php new file mode 100644 index 0000000000..a3d323e7cd --- /dev/null +++ b/library/Icinga/Security/Csp/Reason/CspReason.php @@ -0,0 +1,14 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp\Reason; + +/** + * Base interface for CSP reasons. Only used for type hinting. + * A reason represents the source of a set of CSP directives. + */ +interface CspReason +{ +} diff --git a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php new file mode 100644 index 0000000000..c06e250a4d --- /dev/null +++ b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php @@ -0,0 +1,28 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp\Reason; + +use Icinga\Web\Widget\Dashboard; +use Icinga\Web\Widget\Dashboard\Dashlet; +use Icinga\Web\Widget\Dashboard\Pane; + +/** + * This set of CSP directives is for a dashlet in a dashboard pane. + */ +readonly class DashboardCspReason implements CspReason +{ + /** + * @param Dashboard $dashboard The dashboard to load the CSP directive for + * @param Pane $pane The pane that contains the dashlet + * @param Dashlet $dashlet The dashlet to load the CSP directive for + */ + public function __construct( + public Dashboard $dashboard, + public Pane $pane, + public Dashlet $dashlet, + ) { + } +} diff --git a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php new file mode 100644 index 0000000000..83207ccc0c --- /dev/null +++ b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php @@ -0,0 +1,20 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp\Reason; + +/** + * The reason for a set of CSP directives is that a module has requested them. + */ +readonly class ModuleCspReason implements CspReason +{ + /** + * @param string $module The module to load the CSP directive for + */ + public function __construct( + public string $module, + ) { + } +} diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php new file mode 100644 index 0000000000..8fb786c1be --- /dev/null +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -0,0 +1,31 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp\Reason; + +/** + * The reason for a CSP is a custom user-defined navigation item. + * The item can be bound to a specific user or shared. + */ +readonly class NavigationCspReason implements CspReason +{ + /** + * @param string $type The type of the navigation item + * @param array $typeConfiguration The configuration of the navigation item type + * @param ?string $parent The parent navigation item, if any + * @param string $name The name of the navigation item + * @param bool $isShared Whether the navigation item is shared + * @param ?string $username The username of the user owning the navigation item + */ + public function __construct( + public string $type, + public array $typeConfiguration, + public ?string $parent, + public string $name, + public bool $isShared, + public ?string $username, + ) { + } +} diff --git a/library/Icinga/Security/Csp/Reason/StaticCspReason.php b/library/Icinga/Security/Csp/Reason/StaticCspReason.php new file mode 100644 index 0000000000..75f17e48ec --- /dev/null +++ b/library/Icinga/Security/Csp/Reason/StaticCspReason.php @@ -0,0 +1,21 @@ + +// SPDX-License-Identifier: GPL-3.0-or-later + +namespace Icinga\Security\Csp\Reason; + +/** + * A hardcoded CSP reason. + * Useful for testing or providing a static CSP configuration. + */ +readonly class StaticCspReason implements CspReason +{ + /** + * @param string $name The name to display for CSP reason + */ + public function __construct( + public string $name, + ) { + } +} diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index d5fbdfd52a..efa879b78f 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -5,17 +5,28 @@ namespace Icinga\Util; +use Exception; +use Icinga\Application\Config; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Security\Csp\AttributedCsp; +use Icinga\Security\Csp\Loader\DashboardCspLoader; +use Icinga\Security\Csp\Loader\ModuleCspLoader; +use Icinga\Security\Csp\Loader\NavigationCspLoader; +use Icinga\Security\Csp\Loader\ArrayCspLoader; +use Icinga\User; use Icinga\Web\Response; use Icinga\Web\Window; +use ipl\Stdlib\Str; +use ipl\Web\Common\Csp as CspInstance; use RuntimeException; -use function ipl\Stdlib\get_php_type; - /** - * Helper to enable strict content security policy (CSP) + * Helper to manage the content security policy (CSP) * - * {@see static::addHeader()} adds a strict Content-Security-Policy header with a nonce to still support dynamic CSS - * securely. + * {@see static::addHeader()} adds a Content-Security-Policy header with a nonce + * to still support dynamic CSS securely. * Note that {@see static::createNonce()} must be called first. * Use {@see static::getStyleNonce()} to access the nonce for dynamic CSS. * @@ -24,11 +35,14 @@ */ class Csp { - /** @var static */ - protected static $instance; + /** @var string The session namespace for CSP */ + public const SESSION_NAMESPACE = 'csp'; + + /** @var string The session key for the nonce for dynamic CSS */ + public const SESSION_STYLE_NONCE = 'style_nonce'; - /** @var ?string */ - protected $styleNonce; + /** @var ?CspInstance */ + protected static ?CspInstance $csp = null; /** Singleton */ private function __construct() @@ -36,77 +50,246 @@ private function __construct() } /** - * Add Content-Security-Policy header with a nonce for dynamic CSS + * Add a Content-Security-Policy header with a nonce for dynamic CSS * * Note that {@see static::createNonce()} must be called beforehand. * * @param Response $response * + * @return void + * * @throws RuntimeException If no nonce set for CSS */ public static function addHeader(Response $response): void { - $csp = static::getInstance(); + $header = static::getHeader(); + if (! Str::isEmpty($header)) { + $response->setHeader('Content-Security-Policy', $header, true); + } + } + + /** + * Whether sending the CSP header is enabled + * + * @return bool + */ + public static function isEnabled(): bool + { + return (bool) Config::app()->get('security', 'use_strict_csp', '0'); + } + + /** + * Whether a custom, user-defined CSP header should be used + * + * @return bool + */ + public static function isCustomEnabled(): bool + { + return (bool) Config::app()->get('security', 'use_custom_csp', '0'); + } + + /** + * Whether the CSP header should be automatically generated + * + * This is currently always the opposite of {@see static::isCustomEnabled()} + * as the CSP header is only generated if the custom CSP is not used. But this + * might change in the future. + * + * @return bool + */ + public static function isAutogenerationEnabled(): bool + { + return ! static::isCustomEnabled(); + } + + /** + * Whether the CSP header should be generated for dashboards + * + * @return bool + */ + public static function isDashboardEnabled(): bool + { + if (! static::isAutogenerationEnabled()) { + return false; + } + + return (bool) Config::app()->get('security', 'csp_enable_dashboards', '0'); + } + + /** + * Whether the CSP header should be generated for modules. See {@see CspHook} + * + * @return bool + */ + public static function isModuleEnabled(): bool + { + if (! static::isAutogenerationEnabled()) { + return false; + } + + return (bool) Config::app()->get('security', 'csp_enable_modules', '0'); + } + + /** + * Whether the CSP header should be generated for the navigation + * + * @return bool + */ + public static function isNavigationEnabled(): bool + { + if (! static::isAutogenerationEnabled()) { + return false; + } - if (empty($csp->styleNonce)) { + return (bool) Config::app()->get('security', 'csp_enable_navigation', '0'); + } + + /** + * Get the default CSP for icingaweb + * + * @return AttributedCsp + */ + public static function getSystemCsp(): AttributedCsp + { + $nonce = static::getStyleNonce(); + if ($nonce === null) { throw new RuntimeException('No nonce set for CSS'); } - $response->setHeader( - 'Content-Security-Policy', - "script-src 'self'; style-src 'self' 'nonce-$csp->styleNonce';", - true - ); + return (new ArrayCspLoader('system', [ + 'default-src' => ["'self'"], + 'style-src' => ["'self'", "'nonce-$nonce'"], + 'font-src' => ["'self'", 'data:'], + 'img-src' => ["'self'", 'data:'], + 'frame-src' => ["'self'"], + ]))->loadForAllUsers()[0]; } /** - * Set/recreate nonce for dynamic CSS + * Get the Content-Security-Policy header * - * Should always be called upon initial page loads or page reloads, - * as it sets/recreates a nonce for CSS and writes it to a window-aware session. + * @return string The CSP header for this request */ - public static function createNonce(): void + public static function getHeader(): string { - $csp = static::getInstance(); - $csp->styleNonce = base64_encode(random_bytes(16)); + if (static::$csp !== null) { + return static::$csp->getHeader(); + } + + if (static::isCustomEnabled()) { + try { + static::$csp = self::getCustomHeader(); + } catch (Exception $e) { + Logger::warning('Parsing custom CSP header failed: %s, falling back to system CSP', $e->getMessage()); + static::$csp = self::getSystemCsp()->csp; + } + } else { + $auth = Auth::getInstance(); + $user = $auth->getUser(); + static::$csp = $user === null + ? self::getSystemCsp()->csp + : self::getAutomaticHeader($user); + } - Window::getInstance()->getSessionNamespace('csp')->set('style_nonce', $csp->styleNonce); + return static::$csp->getHeader(); } /** - * Get nonce for dynamic CSS + * Get the custom Content-Security-Policy set in the config * - * @return ?string + * This method automatically replaces new-lines and the {style_nonce} placeholder with the generated nonce. + * + * @return CspInstance The custom CSP header. */ - public static function getStyleNonce(): ?string + protected static function getCustomHeader(): CspInstance { - return static::getInstance()->styleNonce; + $nonce = static::getStyleNonce(); + if (empty($nonce)) { + throw new RuntimeException('No nonce set for CSS'); + } + + $config = Config::app(); + $customCsp = $config->get('security', 'custom_csp', ''); + $customCsp = str_replace('{style_nonce}', "'nonce-$nonce'", $customCsp); + + return CspInstance::fromString($customCsp); } /** - * Get the CSP instance + * Get the automatically generated Content-Security-Policy + * + * @param User $user The user to generate the CSP for * - * @return self + * @return CspInstance The generated header value. */ - protected static function getInstance(): self + protected static function getAutomaticHeader(User $user): CspInstance { - if (static::$instance === null) { - $csp = new static(); - $nonce = Window::getInstance()->getSessionNamespace('csp')->get('style_nonce'); - if ($nonce !== null && ! is_string($nonce)) { - throw new RuntimeException( - sprintf( - 'Nonce value is expected to be string, got %s instead', - get_php_type($nonce) - ) - ); + $attributedCsps = [static::getSystemCsp()]; + + try { + if (Csp::isModuleEnabled()) { + $attributedCsps = array_merge($attributedCsps, (new ModuleCspLoader())->loadForUser($user)); + } + } catch (Exception $e) { + Logger::warning('Module CSP loader failed: %s', $e->getMessage()); + } + + try { + if (Csp::isDashboardEnabled()) { + $attributedCsps = array_merge($attributedCsps, (new DashboardCspLoader())->loadForUser($user)); + } + } catch (Exception $e) { + Logger::warning('Dashboard CSP loader failed: %s', $e->getMessage()); + } + + try { + if (Csp::isNavigationEnabled()) { + $attributedCsps = array_merge($attributedCsps, (new NavigationCspLoader())->loadForUser($user)); } + } catch (Exception $e) { + Logger::warning('Navigation CSP loader failed: %s', $e->getMessage()); + } + + $csps = array_map(fn (AttributedCsp $csp) => $csp->csp, $attributedCsps); + $result = new CspInstance(); - $csp->styleNonce = $nonce; + return $result->merge(...$csps); + } - static::$instance = $csp; + /** + * Ensure a nonce for dynamic CSS exists for the current window + * + * Does nothing if a nonce has already been created for this window's session, + * otherwise generates one and persists it in thet session. Should be called + * on every page load or reload, before {@see static::addHeader()} or + * {@see static::getStyleNonce()} are used. + * + * @return void + */ + public static function createNonce(): void + { + if (Window::getInstance() + ->getSessionNamespace(static::SESSION_NAMESPACE) + ->get(static::SESSION_STYLE_NONCE) === null + ) { + $nonce = base64_encode(random_bytes(16)); + Window::getInstance() + ->getSessionNamespace(static::SESSION_NAMESPACE) + ->set(static::SESSION_STYLE_NONCE, $nonce); + } + } + + /** + * Get nonce for dynamic CSS + * + * @return ?string + */ + public static function getStyleNonce(): ?string + { + if (Icinga::app()->isWeb() && static::$csp !== null) { + return static::$csp->getNonce(); } - return static::$instance; + return Window::getInstance()->getSessionNamespace(static::SESSION_NAMESPACE)->get(static::SESSION_STYLE_NONCE); } } diff --git a/library/Icinga/Web/Response.php b/library/Icinga/Web/Response.php index 3833f50de6..62c1096d51 100644 --- a/library/Icinga/Web/Response.php +++ b/library/Icinga/Web/Response.php @@ -383,7 +383,7 @@ protected function prepare() $this->setRedirect($redirectUrl->getAbsoluteUrl()); } - if (Csp::getStyleNonce() && Config::app()->get('security', 'use_strict_csp', false)) { + if (Csp::getStyleNonce() && Csp::isEnabled()) { Csp::addHeader($this); } } diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index 70b9b7acb4..f798008ad8 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -75,6 +75,7 @@ class StyleSheet 'css/icinga/login.less', 'css/icinga/about.less', 'css/icinga/controls.less', + 'css/icinga/csp-config-editor.less', 'css/icinga/dev.less', 'css/icinga/spinner.less', 'css/icinga/compat.less', diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less new file mode 100644 index 0000000000..f7653aa837 --- /dev/null +++ b/public/css/icinga/csp-config-editor.less @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2026 Icinga GmbH +// SPDX-License-Identifier: GPL-3.0-or-later + +// Layout +.csp-config-table { + display: block; + padding-bottom: 1em; + overflow-x: auto; + + h3 { + margin-top: 0; + + &:not(:first-child) { + margin-top: 1em; + } + } + + th { + min-width: 6em; + } + + th:first-child, + td:first-child { + padding-left: 0; + } + + th:last-child, + td:last-child { + width: 100%; + padding-right: 0; + } + + .csp-expression-info { + margin-left: .5em; + opacity: .7; + } +} + +// Style +.csp-config-table { + text-align: left; + + tr:not(:last-child) { + border-bottom: 1px solid @gray-lighter; + } + + td { + .text-ellipsis(); + } + + th { + font-size: .857em; + font-weight: normal; + letter-spacing: .05em; + } + + th:last-child, + td:last-child { + text-align: right; + } + + .csp-self { + opacity: 0.5; + } + + .csp-warning { + color: @color-warning; + } + + .csp-wildcard, + .csp-critical { + color: @color-critical; + } + + a { + font-weight: bold; + + &:hover { + color: @icinga-blue; + text-decoration: none; + } + } +} + +// Form layout +.csp-config-form { + &:has(.csp-expressions .icon) { + .csp-expressions:not(:has(.icon)) { + padding-right: 2em; + } + th:last-child { + padding-right: 2em; + } + } + + .csp-disabled, + .control-group:has(.csp-disabled) { + opacity: 0.5; + } + + p.csp-form-hint { + opacity: 0.5; + } + + .control-group:has(.csp-form-content-aligned) .control-label-group { + margin-left: 14em; + width: auto; + + label { + text-align: left; + } + } +} + +// Form style +.csp-config-form { + .control-group:has(.csp-label-header-h3, .csp-label-header-h4) { + margin: 0; + } + + .control-group.csp-control-group { + margin: 0; + } + + .control-group:has(.csp-label-header-h3) .control-label-group label { + font-size: 1.167em; + font-weight: bold; + } + + .control-group:has(.csp-label-header-h4) .control-label-group label { + font-weight: bold; + } +}