diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php
index 7a1246fa84..40ace4f674 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
@@ -45,6 +48,14 @@ public function createApplicationTabs()
'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', array(
'title' => $this->translate('Configure which resources are being utilized by Icinga Web 2'),
@@ -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,24 +109,50 @@ 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;
- }
+ $form->handleRequest();
+
+ $this->view->form = $form;
- $appConfigForm = $form->getSubForm('form_config_general_application');
- if ($appConfigForm && (bool) $appConfigForm->getValue('security_use_strict_csp') !== $useStrictCsp) {
+ $this->createApplicationTabs()->activate('general');
+ }
+
+ /**
+ * Security configuration
+ *
+ * @throws SecurityException If the user lacks the permission for configuring the security configuration
+ */
+ public function securityAction(): void
+ {
+ $this->assertPermission('config/security');
+
+ $this->view->title = $this->translate('Security');
+
+ $config = Config::app();
+ $cspForm = new CspConfigForm($config);
+ $cspForm->populate([
+ 'use_strict_csp' => $config->get('security', 'use_strict_csp', '0'),
+ 'use_custom_csp' => $config->get('security', 'use_custom_csp', '0'),
+ 'custom_csp' => $config->get('security', 'custom_csp', ''),
+ 'csp_enable_modules' => $config->get('security', 'csp_enable_modules', '1'),
+ 'csp_enable_dashboards' => $config->get('security', 'csp_enable_dashboards', '1'),
+ 'csp_enable_navigation' => $config->get('security', 'csp_enable_navigation', '1'),
+ ]);
+
+ $cspForm->on(ContractForm::ON_SUBMIT, function (CspConfigForm $form) {
+ if ($form->hasConfigChanged()) {
$this->getResponse()->setReloadWindow(true);
}
- })->handleRequest();
+ Notification::success($this->translate('Content-Security-Policy updated'));
+ });
+ $cspForm->handleRequest(ServerRequest::fromGlobals());
+ $this->view->cspForm = $cspForm;
- $this->view->form = $form;
- $this->view->title = $this->translate('General');
- $this->createApplicationTabs()->activate('general');
+ $this->createApplicationTabs()->activate('security');
}
/**
diff --git a/application/forms/Config/General/ApplicationConfigForm.php b/application/forms/Config/General/ApplicationConfigForm.php
index 96c6a860ca..d33b865822 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..7fbb3a309c
--- /dev/null
+++ b/application/forms/Config/Security/CspConfigForm.php
@@ -0,0 +1,674 @@
+
+// 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\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\CompatForm;
+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 CompatForm
+{
+ use FormUid;
+ use CsrfCounterMeasure;
+
+ /** @var string[] List of all keywords that are considered secure */
+ 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 */
+ protected const WARNING_KEYWORDS = [
+ "'unsafe-inline'",
+ "'unsafe-eval'",
+ "'unsafe-hashes'",
+ ];
+
+ /** @var string[] List of all schemes that are considered secure */
+ protected const SECURE_SCHEMES = [
+ 'https',
+ 'wss',
+ ];
+
+ /** @var string[] List of all schemes that should display a warning */
+ 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 secure */
+ protected const WARNING_DATA_DIRECTIVES = [
+ 'style-src',
+ 'worker-src',
+ 'child-src',
+ 'base-uri',
+ ];
+
+ /**
+ * The number of rows for the custom CSP textarea
+ *
+ * @const int
+ */
+ 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(protected Config $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',
+ '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('use_custom_csp') === '1';
+
+ $formHintClassList = ['csp-form-hint'];
+ if ($useCustomCsp) {
+ $formHintClassList[] = 'csp-disabled';
+ }
+
+ $this->add(HtmlElement::create(
+ 'p',
+ ['class' => $formHintClassList],
+ $this->translate(
+ 'Enabling CSP will block some requests and prevent some functionality from working as expected.'
+ ),
+ ));
+
+ if (! $this->isCspEnabled()) {
+ $this->addElement('hidden', 'use_custom_csp');
+ $this->addElement('hidden', 'custom_csp');
+ $this->addElement('hidden', 'csp_enable_modules');
+ $this->addElement('hidden', 'csp_enable_dashboards');
+ $this->addElement('hidden', 'csp_enable_navigation');
+ } else {
+ $this->add(HtmlElement::create(
+ 'h3',
+ ['class' => $formHintClassList],
+ $this->translate('Allowed Sources'),
+ ));
+
+ $this->add(HtmlElement::create(
+ 'p',
+ ['class' => $formHintClassList],
+ $this->translate(
+ 'Sources that are used in the generation of the CSP-Header.'
+ ),
+ ));
+
+ $this->add(HtmlElement::create(
+ 'h4',
+ ['class' => $formHintClassList],
+ $this->translate('System'),
+ ));
+
+ $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?'
+ . ' Note: Modules can define or change csp directives at any point.'
+ ),
+ 'csp_enable_modules',
+ ! $useCustomCsp,
+ );
+
+ $this->addDirectiveContentElement(
+ (new ModuleCspLoader())->load(true),
+ [$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 === false && $this->getValue('csp_enable_modules') === '1',
+ $this->translate('No module policies defined.')
+ );
+
+ $this->addDirectiveCheckboxElement(
+ $this->translate('Enable Dashboards'),
+ $this->translate(
+ 'Enable user defined dashboards. Note: 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.'
+ ),
+ 'csp_enable_dashboards',
+ ! $useCustomCsp,
+ );
+
+ $this->addDirectiveContentElement(
+ (new DashboardCspLoader())->load(true),
+ [
+ $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 === false && $this->getValue('csp_enable_dashboards') === '1',
+ $this->translate('No dashboard policies found.'),
+ );
+
+ $this->addDirectiveCheckboxElement(
+ $this->translate('Enable Navigation Items'),
+ $this->translate(
+ 'Enable user defined navigation items. Note: 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.'
+ ),
+ 'csp_enable_navigation',
+ ! $useCustomCsp,
+ );
+
+ $this->addDirectiveContentElement(
+ (new NavigationCspLoader())->load(true),
+ [
+ $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) {
+ if ($reason->parent === null) {
+ $parentCell = Table::td($this->translate('None'))->setAttribute('class', 'empty-state');
+ } else {
+ $parentCell = 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,
+ };
+ if ($reason->username === null) {
+ $userCell = Table::td([$sharedIcon, $this->translate('Unknown')])
+ ->setAttribute('class', 'empty-state');
+ } else {
+ $userCell = 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 === false && $this->getValue('csp_enable_navigation') === '1',
+ $this->translate('No navigation policies found.'),
+ );
+
+ $this->addElement(
+ 'checkbox',
+ '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->addHtml((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!'),
+ ))->setFormElement());
+ }
+
+ $this->addElement('textarea', '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(),
+ 'validators' => [
+ new CallbackValidator(function ($value, CallbackValidator $validator) {
+ if (empty($value)) {
+ return true;
+ }
+
+ try {
+ $value = str_replace('{style_nonce}', "'nonce-validation'", $value);
+ CspInstance::fromString($value);
+ } catch (Exception $e) {
+ $validator->addMessage($e->getMessage());
+ return false;
+ }
+
+ return true;
+ }),
+ ]
+ ]);
+ }
+
+ $this->addElement('submit', 'submit', [
+ 'label' => $this->translate('Save changes'),
+ ]);
+ }
+
+ protected function onSuccess(): void
+ {
+ $section = $this->config->getSection('security');
+ $beforeSection = clone $section;
+ $section['use_strict_csp'] = $this->getValue('use_strict_csp');
+ $section['csp_enable_modules'] = $this->getValue('csp_enable_modules');
+ $section['csp_enable_dashboards'] = $this->getValue('csp_enable_dashboards');
+ $section['csp_enable_navigation'] = $this->getValue('csp_enable_navigation');
+ $section['use_custom_csp'] = $this->getValue('use_custom_csp');
+ $section['custom_csp'] = $this->getValue('custom_csp', '');
+
+ $a = iterator_to_array($section);
+ $b = iterator_to_array($beforeSection);
+ $this->changed = ! empty(array_diff_assoc($a, $b)) || ! empty(array_diff_assoc($b, $a));
+
+ if (! $this->changed) {
+ return;
+ }
+
+ $this->config->setSection('security', $section);
+
+ $this->config->saveIni();
+ }
+
+ /**
+ * 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('use_strict_csp') === '1';
+ }
+
+ /**
+ * Would custom CSP be enabled if the form contents where submitted?
+ *
+ * @return bool
+ */
+ public function isCustomCspEnabled(): bool
+ {
+ return $this->getValue('use_custom_csp') === '1';
+ }
+
+ 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->add(
+ HtmlElement::create('p', ['class' => 'csp-form-hint'], $emptyText)
+ );
+ 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(HtmlElement::create(
+ 'div',
+ [
+ 'class' => 'collapsible',
+ 'data-visible-height' => 100,
+ ],
+ $table,
+ ));
+ }
+
+ /**
+ * Categorize the expression keywords into secure, warning, and unknown
+ *
+ * @param string $expression
+ *
+ * @return string|null
+ */
+ protected function getKeywordType(string $expression): ?string
+ {
+ if (in_array($expression, static::SECURE_KEYWORDS)) {
+ return 'secure';
+ }
+
+ if (in_array($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, ':')) {
+ return null;
+ }
+
+ if (str_contains($expression, ' ')) {
+ return null;
+ }
+
+ $scheme = 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' && in_array($directive, static::CRITICAL_DATA_DIRECTIVES)) {
+ return 'critical';
+ }
+
+ if ($scheme === 'data' && in_array($directive, static::WARNING_DATA_DIRECTIVES)) {
+ return 'warning';
+ }
+
+ return 'unknown';
+ }
+
+ /**
+ * Whether the given expression is a nonce
+ *
+ * @param string $expression
+ *
+ * @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 02cba4ce0f..3ad94ed8b7 100644
--- a/application/forms/Dashboard/DashletForm.php
+++ b/application/forms/Dashboard/DashletForm.php
@@ -5,12 +5,15 @@
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\Widget\Callout;
/**
* Form to add an url a dashboard pane
@@ -75,6 +78,24 @@ public function createElements(array $formData)
)
);
+ if (Csp::isEnabled() && ! Csp::isDashboardEnabled()) {
+ $this->addElement(
+ 'note',
+ 'csp_warning',
+ [
+ 'decorators' => ['ViewHelper'],
+ 'value' => (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'),
+ ))->setFormElement()->render(),
+ ]
+ );
+ }
+
$this->addElement(
'textarea',
'url',
diff --git a/application/forms/Navigation/NavigationItemForm.php b/application/forms/Navigation/NavigationItemForm.php
index 0efe96d9c9..5029ab5901 100644
--- a/application/forms/Navigation/NavigationItemForm.php
+++ b/application/forms/Navigation/NavigationItemForm.php
@@ -5,8 +5,11 @@
namespace Icinga\Forms\Navigation;
+use Icinga\Util\Csp;
use Icinga\Web\Form;
use Icinga\Web\Url;
+use ipl\Web\Common\CalloutType;
+use ipl\Web\Widget\Callout;
class NavigationItemForm extends Form
{
@@ -48,6 +51,24 @@ public function createElements(array $formData)
)
);
+ if (Csp::isEnabled() && ! Csp::isNavigationEnabled()) {
+ $this->addElement(
+ 'note',
+ 'csp_warning',
+ [
+ 'decorators' => ['ViewHelper'],
+ 'value' => (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'),
+ ))->setFormElement()->render(),
+ ]
+ );
+ }
+
$this->addElement(
'textarea',
'url',
diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php
index ea64fd0cbc..37ce3e0677 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 @@
+
+ = $tabs ?>
+
+
+
= $this->translate('Content Security Policy') ?>
+ = $cspForm ?>
+
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..88b47d6ca1 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..14581178b5 100644
--- a/doc/60-Hooks.md
+++ b/doc/60-Hooks.md
@@ -47,3 +47,33 @@ class ConfigFormEvents extends ConfigFormEventsHook
}
}
```
+
+## CspHook
+
+The `CspHook` allows developers to add custom CSP directives to the Icinga Web 2 frontend.
+It provides the method `getCsp()` which should return a `Csp` instance with the directives the module wants to add.
+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;
+
+class Csp extends CspHook
+{
+ public function getCsp(bool $allUsers): CspInstance
+ {
+ $csp = new CspInstance();
+ $csp->add('img-src', ['cdn.example.com', 'usercontent.example.com']);
+ $csp->add('style-src', 'cdn.example.com');
+
+ // ...
+
+ return $csp;
+ }
+}
+```
diff --git a/library/Icinga/Application/Hook/CspHook.php b/library/Icinga/Application/Hook/CspHook.php
new file mode 100644
index 0000000000..23d63676f3
--- /dev/null
+++ b/library/Icinga/Application/Hook/CspHook.php
@@ -0,0 +1,47 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Application\Hook;
+
+use Icinga\Application\Hook;
+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
+{
+ /**
+ * Get the CSP directives for a module
+ *
+ * @param bool $allUsers Whether the Csp should contain directives for all users
+ * or only for the currently authenticated user.
+ *
+ * @return Csp A CSP instance with the required policies, this instance will
+ * be merged with all other requested directives.
+ */
+ abstract public function getCsp(bool $allUsers): Csp;
+
+ /**
+ * Get all registered implementations
+ *
+ * @return static[]
+ */
+ public static function all(): array
+ {
+ return Hook::all('Csp');
+ }
+
+ /**
+ * Register the class as a CspHook implementation
+ *
+ * Call this method on your implementation during module initialization to make Icinga Web aware of your hook.
+ */
+ public static function register(): void
+ {
+ Hook::register('Csp', static::class, static::class, true);
+ }
+}
diff --git a/library/Icinga/Security/Csp/AttributedCsp.php b/library/Icinga/Security/Csp/AttributedCsp.php
new file mode 100644
index 0000000000..f1db6dca5f
--- /dev/null
+++ b/library/Icinga/Security/Csp/AttributedCsp.php
@@ -0,0 +1,21 @@
+
+// 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
+{
+ public function __construct(
+ public Csp $csp,
+ public CspReason $reason,
+ ) {
+ }
+}
diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php
new file mode 100644
index 0000000000..8d1838dcc2
--- /dev/null
+++ b/library/Icinga/Security/Csp/Loader/CspLoader.php
@@ -0,0 +1,25 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Loader;
+
+use Icinga\Security\Csp\AttributedCsp;
+
+/**
+ * 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
+ *
+ * @param bool $allUsers Whether the CSP should contain directives for all
+ * users or only for the currently authenticated user.
+ *
+ * @return AttributedCsp[]
+ */
+ public function load(bool $allUsers = false): 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..ef40880558
--- /dev/null
+++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php
@@ -0,0 +1,99 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Loader;
+
+use DirectoryIterator;
+use Icinga\Application\Config;
+use Icinga\Authentication\Auth;
+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
+{
+ /**
+ * Loads CSP directives for external URLs in dashboard panes for a specific user
+ *
+ * @param User $user The user to load the CSP directives for
+ *
+ * @return AttributedCsp[]
+ */
+ protected 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;
+ }
+
+ $absoluteUrl = $url->isExternal()
+ ? $url->getAbsoluteUrl()
+ : $url->getParam('url');
+ if ($absoluteUrl === null || filter_var($absoluteUrl, FILTER_VALIDATE_URL) === false) {
+ continue;
+ }
+
+ $absoluteUrl = Url::fromPath($absoluteUrl);
+
+ $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 load(bool $allUsers = false): array
+ {
+ $auth = Auth::getInstance();
+ if (! $auth->isAuthenticated()) {
+ return [];
+ }
+
+ if ($allUsers) {
+ $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;
+ } else {
+ return $this->loadForUser($auth->getUser());
+ }
+ }
+}
diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php
new file mode 100644
index 0000000000..9cf092f7c3
--- /dev/null
+++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php
@@ -0,0 +1,43 @@
+
+// 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\Security\Csp\AttributedCsp;
+use Icinga\Security\Csp\Reason\ModuleCspReason;
+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 load(bool $allUsers = false): array
+ {
+ $result = [];
+
+ foreach (CspHook::all() as $hook) {
+ try {
+ $csp = $hook->getCsp($allUsers);
+ if ($csp->isEmpty()) {
+ continue;
+ }
+ $result[] = new AttributedCsp(
+ $csp,
+ new ModuleCspReason(ClassLoader::extractModuleName(get_class($hook))),
+ );
+ } catch (Throwable $e) {
+ Logger::warning('Failed to invoke CSP hook: %s', $e->getMessage());
+ }
+ }
+
+ 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..a529b4ee48
--- /dev/null
+++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php
@@ -0,0 +1,187 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Loader;
+
+use DirectoryIterator;
+use Icinga\Application\Config;
+use Icinga\Authentication\Auth;
+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
+{
+ /**
+ * Loads CSP directives for navigation items that have an external URL
+ *
+ * @param string $type The navigation type
+ * @param array $typeConfig The navigation type configuration
+ * @param ?string $username The optional username to load the configuration for.
+ * If not provided, the shared configuration is loaded.
+ * @param ?User $currentUser The optional user to check access for.
+ * If provided, access restrictions are checked.
+ *
+ * @return AttributedCsp[]
+ */
+ protected function loadConfig(
+ string $type,
+ array $typeConfig,
+ ?string $username = null,
+ ?User $currentUser = null
+ ): array {
+ $config = Config::navigation($type, $username);
+ if ($config->isEmpty()) {
+ return [];
+ }
+
+ $result = [];
+ foreach ($config as $sectionName => $section) {
+ if ($section->isEmpty()) {
+ continue;
+ }
+
+ if ($section->get('target') === '_blank') {
+ continue;
+ }
+
+ if ($section->get('url') === null) {
+ continue;
+ }
+
+ $owner = $section->get('owner');
+ if ($currentUser !== null && ! $this->hasAccessToSharedNavigationItem($section, $config, $currentUser)) {
+ continue;
+ }
+
+ if (filter_var($section['url'], FILTER_VALIDATE_URL) === false) {
+ continue;
+ }
+
+ $url = Url::fromPath($section['url']);
+ $cspUrl = $url->getScheme() . '://' . $url->getHost();
+ if (($port = $url->getPort()) !== null) {
+ $cspUrl .= ':' . $port;
+ }
+
+ $parent = $section->get('parent');
+ $isShared = $username === null;
+
+ $csp = new Csp();
+ $csp->add('frame-src', $cspUrl);
+ $result[] = new AttributedCsp($csp, new NavigationCspReason(
+ $type,
+ $typeConfig,
+ $parent,
+ $sectionName,
+ $isShared,
+ $username ?? $owner,
+ ));
+ }
+
+ return $result;
+ }
+
+ public function load(bool $allUsers = false): array
+ {
+ $auth = Auth::getInstance();
+ if (! $auth->isAuthenticated()) {
+ return [];
+ }
+
+ $result = [];
+ $navigationTypes = Navigation::getItemTypeConfiguration();
+ if ($allUsers) {
+ foreach ($navigationTypes as $type => $typeConfig) {
+ $result = array_merge($result, $this->loadConfig($type, $typeConfig));
+ $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->loadConfig($type, $typeConfig, $userDir->getFilename()));
+ }
+ }
+ } else {
+ $username = $auth->getUser()->getUsername();
+ foreach ($navigationTypes as $type => $typeConfig) {
+ $result = array_merge($result, $this->loadConfig(
+ $type,
+ $typeConfig,
+ currentUser: $auth->getUser(),
+ ));
+ $result = array_merge($result, $this->loadConfig(
+ $type,
+ $typeConfig,
+ $username,
+ currentUser: $auth->getUser(),
+ ));
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Checks 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/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php
new file mode 100644
index 0000000000..c206e99a57
--- /dev/null
+++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php
@@ -0,0 +1,38 @@
+
+// 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 ipl\Web\Common\Csp;
+
+/**
+ * Loads CSP directives from a static array.
+ * Useful for testing or providing a static CSP configuration.
+ */
+class StaticCspLoader 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,
+ ) {
+ }
+
+ public function load(bool $allUsers = false): 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/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..cb892b942c
--- /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|null $parent
+ * @param string $name
+ * @param bool $isShared
+ * @param string|null $username
+ */
+ 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..a85bd48bf6
--- /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..c8ed11b12e 100644
--- a/library/Icinga/Util/Csp.php
+++ b/library/Icinga/Util/Csp.php
@@ -5,12 +5,20 @@
namespace Icinga\Util;
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+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\StaticCspLoader;
use Icinga\Web\Response;
use Icinga\Web\Window;
+use ipl\Web\Common\Csp as CspInstance;
use RuntimeException;
-use function ipl\Stdlib\get_php_type;
-
/**
* Helper to enable strict content security policy (CSP)
*
@@ -24,11 +32,8 @@
*/
class Csp
{
- /** @var static */
- protected static $instance;
-
- /** @var ?string */
- protected $styleNonce;
+ /** @var ?CspInstance */
+ protected static ?CspInstance $csp = null;
/** Singleton */
private function __construct()
@@ -36,7 +41,7 @@ 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.
*
@@ -46,67 +51,203 @@ private function __construct()
*/
public static function addHeader(Response $response): void
{
- $csp = static::getInstance();
+ $response->setHeader('Content-Security-Policy', static::getHeader(), true);
+ }
+
+ /**
+ * Check whether sending the CSP header is enabled
+ * @return bool
+ */
+ public static function isEnabled(): bool
+ {
+ return (bool) Config::app()->get('security', 'use_strict_csp', '0');
+ }
+
+ /**
+ * Returns 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');
+ }
- if (empty($csp->styleNonce)) {
+ /**
+ * Returns if the CSP header should be automatically generated
+ * Note: 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();
+ }
+
+ /**
+ * Returns 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', '1');
+ }
+
+ /**
+ * Returns 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', '1');
+ }
+
+ /**
+ * Returns whether the CSP header should be generated for the navigation
+ *
+ * @return bool
+ */
+ public static function isNavigationEnabled(): bool
+ {
+ if (! static::isAutogenerationEnabled()) {
+ return false;
+ }
+
+ return (bool) Config::app()->get('security', 'csp_enable_navigation', '1');
+ }
+
+ public static function getSystemCsp(): AttributedCsp
+ {
+ $nonce = static::getStyleNonce();
+ if (empty($nonce)) {
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 StaticCspLoader(
+ 'system',
+ [
+ /* There is no need to define `default-src` here, as it is already defined in the base CSP */
+ 'style-src' => ["'self'", "'nonce-$nonce'"],
+ 'font-src' => ["'self'", "data:"],
+ 'img-src' => ["'self'", "data:"],
+ 'frame-src' => ["'self'"],
+ ],
+ ))->load()[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 Returns the CSP header for this request.
+ * @throws RuntimeException If no nonce set for CSS
*/
- public static function createNonce(): void
+ public static function getHeader(): string
{
- $csp = static::getInstance();
- $csp->styleNonce = base64_encode(random_bytes(16));
+ if (static::$csp === null) {
+ $config = Config::app();
+ if ($config->get('security', 'use_custom_csp', '0') === '1') {
+ static::$csp = self::getCustomHeader();
+ } else {
+ static::$csp = self::getAutomaticHeader();
+ }
+ }
- 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.
+ * This method automatically replaces new-lines and the {style_nonce} placeholder with the generated nonce.
*
- * @return ?string
+ * @return CspInstance Returns 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
+ *
+ * @return CspInstance Returns the generated header value.
*
- * @return self
+ * @throws RuntimeException If no nonce set for CSS
*/
- protected static function getInstance(): self
+ protected static function getAutomaticHeader(): 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())->load());
}
+ } catch (Exception $e) {
+ Logger::warning('Module CSP loader failed: %s', $e->getMessage());
+ }
- $csp->styleNonce = $nonce;
+ try {
+ if (Csp::isDashboardEnabled()) {
+ $attributedCsps = array_merge($attributedCsps, (new DashboardCspLoader())->load());
+ }
+ } catch (Exception $e) {
+ Logger::warning('Dashboard CSP loader failed: %s', $e->getMessage());
+ }
+
+ try {
+ if (Csp::isNavigationEnabled()) {
+ $attributedCsps = array_merge($attributedCsps, (new NavigationCspLoader())->load());
+ }
+ } catch (Exception $e) {
+ Logger::warning('Navigation CSP loader failed: %s', $e->getMessage());
+ }
- static::$instance = $csp;
+ $csps = array_map(fn (AttributedCsp $csp) => $csp->csp, $attributedCsps);
+
+ return CspInstance::merge(...$csps);
+ }
+
+ /**
+ * Set/recreate nonce for dynamic CSS
+ *
+ * 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.
+ */
+ public static function createNonce(): void
+ {
+ if (Window::getInstance()->getSessionNamespace('csp')->get('style_nonce') === null) {
+ $nonce = base64_encode(random_bytes(16));
+ Window::getInstance()->getSessionNamespace('csp')->set('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('csp')->get('style_nonce');
}
}
diff --git a/library/Icinga/Web/Response.php b/library/Icinga/Web/Response.php
index 19c25ddbb6..6cb25ae163 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 fc7a025f12..e2039b08a4 100644
--- a/library/Icinga/Web/StyleSheet.php
+++ b/library/Icinga/Web/StyleSheet.php
@@ -74,6 +74,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..b780d2e603
--- /dev/null
+++ b/public/css/icinga/csp-config-editor.less
@@ -0,0 +1,153 @@
+// SPDX-FileCopyrightText: 2026 Icinga GmbH
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Layout
+.csp-config-table {
+ overflow-x: auto;
+ display: block;
+ padding-bottom: 1em;
+
+ h3 {
+ margin-top: 0;
+
+ &:not(:first-child) {
+ margin-top: 1em;
+ }
+ }
+
+ th {
+ min-width: 6em;
+ }
+
+ th:first-child,
+ td:first-child {
+ padding-right: 0;
+ }
+
+ th:last-child,
+ td:last-child {
+ width: 100%;
+ }
+
+ .csp-expressions {
+ display: flex;
+ flex-direction: row;
+ justify-content: end;
+ gap: 0.25em;
+ }
+
+ .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 {
+ .csp-config-table {
+ margin-left: 14em;
+ overflow-y: hidden;
+ }
+
+ &: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 {
+ margin-left: 14em;
+ opacity: 0.5;
+ }
+
+ h3.csp-form-hint {
+ margin-left: 12em;
+ }
+
+ h4.csp-form-hint {
+ margin-left: 14em;
+ }
+
+ .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: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;
+ }
+
+ .control-group:has(.csp-form-header) {
+ margin-top: 2em;
+ }
+}