diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php
index 7a1246fa84..2ce1bee1d0 100644
--- a/application/controllers/ConfigController.php
+++ b/application/controllers/ConfigController.php
@@ -6,7 +6,9 @@
namespace Icinga\Controllers;
use Exception;
+use GuzzleHttp\Psr7\ServerRequest;
use Icinga\Application\Version;
+use Icinga\Util\Csp;
use InvalidArgumentException;
use Icinga\Application\Config;
use Icinga\Application\Icinga;
@@ -17,6 +19,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 +28,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 +49,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'),
@@ -96,24 +108,49 @@ 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' => Csp::isEnabled(),
+ '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) use ($config) {
+ if ($form->hasConfigChanged()) {
$this->getResponse()->setReloadWindow(true);
}
- })->handleRequest();
+ });
+ $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..057a5859a5
--- /dev/null
+++ b/application/forms/Config/Security/CspConfigForm.php
@@ -0,0 +1,597 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Forms\Config\Security;
+
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Data\ConfigObject;
+use Icinga\Security\Csp\LoadedCsp;
+use Icinga\Security\Csp\Reason\CspReason;
+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;
+
+class CspConfigForm extends CompatForm
+{
+ use FormUid;
+ use CsrfCounterMeasure;
+
+ /** @var string[] */
+ protected const SECURE_KEYWORDS = [
+ "'self'",
+ "'none'",
+ "'strict-dynamic'",
+ "'report-sample'",
+ "'report-sha256'",
+ "'report-sha384'",
+ "'report-sha512'",
+ ];
+
+ /** @var string[] */
+ protected const WARNING_KEYWORDS = [
+ "'unsafe-inline'",
+ "'unsafe-eval'",
+ "'unsafe-hashes'",
+ ];
+
+ /** @var string[] */
+ protected const SECURE_SCHEMAS = [
+ 'https',
+ 'wss',
+ ];
+
+ /** @var string[] */
+ protected const WARNING_SCHEMAS = [
+ 'http',
+ 'ws',
+ 'blob',
+ ];
+
+ /** @var string[] */
+ protected const CRITICAL_DATA_DIRECTIVES = [
+ 'default-src',
+ 'script-src',
+ 'object-src',
+ 'frame-src',
+ ];
+
+ /** @var string[] */
+ protected const WARNING_DATA_DIRECTIVES = [
+ 'style-src',
+ 'worker-src',
+ 'child-src',
+ 'base-uri',
+ ];
+
+ /**
+ * The number of rows for the CUSTOMS CSP textarea
+ *
+ * @const int
+ */
+ protected const TEXTAREA_ROWS = 8;
+
+ protected bool $changed = false;
+
+ 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();
+ $csps = Csp::load(new ConfigObject([
+ 'csp_enable_modules' => '1',
+ 'csp_enable_dashboards' => '1',
+ 'csp_enable_navigation' => '1',
+ ]));
+
+ $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',
+ ],
+ );
+
+ $disabledState = $this->getPopulatedValue('use_custom_csp') === '1';
+ $disabledClass = $disabledState ? 'csp-disabled' : '';
+
+ $this->add(HtmlElement::create(
+ 'p',
+ ['class' => ['csp-form-hint', $disabledClass]],
+ $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' => ['csp-form-hint', $disabledClass]],
+ $this->translate('Allowed Sources'),
+ ));
+
+ $this->add(HtmlElement::create(
+ 'p',
+ ['class' => ['csp-form-hint', $disabledClass]],
+ $this->translate(
+ 'Sources that are used in the generation of the CSP-Header.'
+ ),
+ ));
+
+ $this->addPolicyTitleElement($this->translate('System'), 'unused', null, ! $disabledState);
+ $this->addPolicyContentElement(
+ $csps,
+ [t('Directive'), t('Value')],
+ function (CspReason $reason) {
+ return $reason instanceof StaticCspReason
+ && $reason->name === 'system';
+ },
+ function (StaticCspReason $reason, string $directive, string $policy) {
+ return Table::tr([
+ Table::td($directive),
+ $this->buildPolicy($directive, $policy),
+ ]);
+ },
+ ! $disabledState,
+ $this->translate('No system policies defined.')
+ );
+
+ $this->addPolicyTitleElement(
+ $this->translate('Modules'),
+ $this->translate(
+ 'Should module defined csp directives be enabled?'
+ . ' Note: Modules can define or change csp directives at any point.'
+ ),
+ 'csp_enable_modules',
+ ! $disabledState,
+ );
+
+ $this->addPolicyContentElement(
+ $csps,
+ [t('Module'), t('Directive'), t('Value')],
+ function (CspReason $reason) {
+ return $reason instanceof ModuleCspReason;
+ },
+ function (ModuleCspReason $reason, string $directive, string $policy) {
+ return Table::tr([
+ Table::td($reason->module),
+ Table::td($directive),
+ $this->buildPolicy($directive, $policy),
+ ]);
+ },
+ $disabledState === false && $this->getValue('csp_enable_modules') === '1',
+ $this->translate('No module policies defined.')
+ );
+
+ $this->addPolicyTitleElement(
+ $this->translate('Dashboard'),
+ $this->translate(
+ 'Enable user defined dashboards. Note: You will only be able to see your own dashboards,'
+ . ' and there is currently no way to see what others have configured for themselves.'
+ ),
+ 'csp_enable_dashboards',
+ ! $disabledState,
+ );
+
+ $this->addPolicyContentElement(
+ $csps,
+ [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')],
+ function (CspReason $reason) {
+ return $reason instanceof DashboardCspReason;
+ },
+ function (DashboardCspReason $reason, string $directive, string $policy) {
+ return Table::tr([
+ Table::td($reason->pane->getName()),
+ Table::td($reason->dashlet->getName()),
+ Table::td($directive),
+ $this->buildPolicy($directive, $policy),
+ ]);
+ },
+ $disabledState === false && $this->getValue('csp_enable_dashboards') === '1',
+ $this->translate('No dashboard policies found.'),
+ );
+
+ $this->addPolicyTitleElement(
+ $this->translate('Navigation'),
+ $this->translate(
+ 'Enable navigation items. Note: You will only be able to see your own navigation items,'
+ . ' and there is currently no way to see what others have configured for themselves.'
+ ),
+ 'csp_enable_navigation',
+ ! $disabledState,
+ );
+
+ $this->addPolicyContentElement(
+ $csps,
+ [t('Navigation'), t('Parent'), t('Name'), t('Directive'), t('Value')],
+ function (CspReason $reason) {
+ return $reason instanceof NavigationCspReason;
+ },
+ function (NavigationCspReason $reason, string $directive, string $policy) {
+ $parent = $reason->item->getParent();
+ if ($parent === null) {
+ $parentCell = Table::td(t('None'))->setAttribute('class', 'empty-state');
+ } else {
+ $parentCell = Table::td($parent->getName());
+ }
+ return Table::tr([
+ Table::td($reason->typeConfiguration['label'] ?? $reason->type),
+ $parentCell,
+ Table::td($reason->item->getName()),
+ Table::td($directive),
+ $this->buildPolicy($directive, $policy),
+ ]);
+ },
+ $disabledState === 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' => $this->translate(''),
+ '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' => t('Save changes'),
+ ]);
+ }
+
+ protected function onSuccess(): void
+ {
+ $config = Config::app();
+
+ $section = $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', '');
+
+ $this->changed = ! empty(array_diff_assoc(
+ iterator_to_array($section),
+ iterator_to_array($beforeSection)
+ ));
+
+ if (! $this->changed) {
+ return;
+ }
+
+ $config->setSection('security', $section);
+
+ $config->saveIni();
+ }
+
+ public function hasConfigChanged(): bool
+ {
+ return $this->changed;
+ }
+
+ public function isCspEnabled(): bool
+ {
+ return $this->getValue('use_strict_csp') === '1';
+ }
+
+ public function isCustomCspEnabled(): bool
+ {
+ return $this->getPopulatedValue('use_custom_csp') === '1';
+ }
+
+ /**
+ * @param string $title the title of the section
+ * @param string|null $description the description of the section
+ * @param string|null $field the name of the checkbox that controls the section
+ * @param bool $enabled whether the section should be enabled
+ *
+ * @return void
+ */
+ protected function addPolicyTitleElement(
+ string $title,
+ ?string $description,
+ ?string $field,
+ bool $enabled,
+ ): void {
+ $disabledClass = $enabled ? '' : 'csp-disabled';
+
+ if ($field == null) {
+ $this->add(HtmlElement::create('h4', ['class' => "csp-form-hint $disabledClass"], $title));
+ return;
+ }
+
+ $this->addElement('checkbox', $field, [
+ 'label' => sprintf($this->translate('Enable %s'), $title),
+ 'description' => $description,
+ 'class' => "autosubmit csp-form-content-aligned csp-label-header-h4 $disabledClass",
+ 'checkedValue' => '1',
+ 'uncheckedValue' => '0',
+ 'disabled' => ! $enabled,
+ 'value' => $this->getPopulatedValue($field),
+ ]);
+ }
+
+ /**
+ * @param LoadedCsp[] $csps the list of cps along with their reasons
+ * @param string[] $header the header of the table
+ * @param callable $filter a filter function that returns true if the csp should be included in 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 addPolicyContentElement(
+ array $csps,
+ array $header,
+ callable $filter,
+ callable $rowBuilder,
+ bool $enabled,
+ string $emptyText,
+ ): void {
+ $rows = [];
+ foreach ($csps as $csp) {
+ if (! $filter($csp->loadReason)) {
+ continue;
+ }
+ foreach ($csp->getDirectives() as $directive => $policies) {
+ foreach ($policies as $policy) {
+ $rows[] = $rowBuilder($csp->loadReason, $directive, $policy);
+ }
+ }
+ }
+
+ if (count($rows) === 0) {
+ $this->add(
+ HtmlElement::create('p', ['class' => 'csp-form-hint'], $emptyText)
+ );
+ return;
+ }
+
+ $table = new Table();
+ $table->addAttributes(Attributes::create(['class' => ['csp-config-table', $enabled ? '' : 'csp-disabled']]));
+ $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,
+ ));
+ }
+
+ protected function getKeywordType(string $policy): ?string
+ {
+ if (in_array($policy, static::SECURE_KEYWORDS)) {
+ return 'secure';
+ }
+
+ if (in_array($policy, static::WARNING_KEYWORDS)) {
+ return 'warning';
+ }
+
+ return null;
+ }
+
+ protected function getSchemeType(string $directive, string $policy): ?string
+ {
+ if (! str_ends_with($policy, ':')) {
+ return null;
+ }
+
+ if (str_contains($policy, ' ')) {
+ return null;
+ }
+
+ $schema = substr($policy, 0, -1);
+
+ if (in_array($schema, static::SECURE_SCHEMAS)) {
+ return 'secure';
+ }
+
+ if (in_array($schema, static::WARNING_SCHEMAS)) {
+ return 'warning';
+ }
+
+ if ($schema === 'data' && in_array($directive, static::CRITICAL_DATA_DIRECTIVES)) {
+ return 'critical';
+ }
+
+ if ($schema === 'data' && in_array($directive, static::WARNING_DATA_DIRECTIVES)) {
+ return 'warning';
+ }
+
+ return 'unknown';
+ }
+
+ protected function isNonce(string $policy): bool
+ {
+ return (str_starts_with($policy, "'nonce-") && str_ends_with($policy, "'"));
+ }
+
+ protected function buildPolicy(string $directive, string $policy): BaseHtmlElement
+ {
+ if ($policy === '*') {
+ $result = HtmlElement::create(
+ 'span',
+ ['class' => 'csp-wildcard'],
+ [
+ $policy,
+ new Icon(
+ 'warning',
+ [
+ 'class' => 'csp-policy-info',
+ 'title' => t(
+ 'This is a wildcard policy. It allows everything and should therefore be avoided.'
+ ),
+ ]
+ ),
+ ],
+ );
+ } elseif (($keyword = $this->getKeywordType($policy)) !== null) {
+ $icon = match ($keyword) {
+ 'warning' => new Icon(
+ 'warning',
+ [
+ 'class' => 'csp-policy-info',
+ 'title' => t('This is a potentially unsafe keyword.'),
+ ]
+ ),
+ default => null,
+ };
+ $result = HtmlElement::create(
+ 'span',
+ ['class' => ['csp-keyword', 'csp-' . $keyword]],
+ [
+ $policy,
+ $icon,
+ ]
+ );
+ } elseif (($scheme = $this->getSchemeType($directive, $policy)) !== null) {
+ $icon = match ($scheme) {
+ 'warning' => new Icon(
+ 'warning',
+ [
+ 'class' => 'csp-policy-info',
+ 'title' => t('This is a potentially unsafe scheme.'),
+ ]
+ ),
+ 'critical' => new Icon(
+ 'warning',
+ [
+ 'class' => 'csp-policy-info',
+ 'title' => t('This is a critical scheme and should not be used.'),
+ ]
+ ),
+ default => null,
+ };
+ $result = HtmlElement::create(
+ 'span',
+ ['class' => ['csp-scheme', 'csp-' . $scheme]],
+ [
+ $policy,
+ $icon,
+ ]
+ );
+ } elseif ($this->isNonce($policy)) {
+ $result = HtmlElement::create(
+ 'span',
+ ['class' => 'csp-nonce'],
+ [
+ $policy,
+ new Icon(
+ 'info-circle',
+ [
+ 'class' => 'csp-policy-info',
+ 'title' => t('This is an automatically generated nonce. Its value is unique per request.'),
+ ],
+ ),
+ ]
+ );
+ } elseif (filter_var($policy, FILTER_VALIDATE_URL) !== false) {
+ $result = new Link($policy, $policy, ['target' => '_blank']);
+ } else {
+ $result = new Text($policy);
+ }
+ return Table::td($result, ['class' => 'csp-policies']);
+ }
+}
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..8e66a08c13 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 d3e0a60846..131e482950 100644
--- a/doc/20-Advanced-Topics.md
+++ b/doc/20-Advanced-Topics.md
@@ -121,24 +121,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 behaviour is
+not desired, you can disable both by disabling the corresponding feature in the **Security page** at
+**Configuration > Security** or by setting `csp_enable_navigation` or `csp_enable_dashboards` in the `config.ini`.
+Note that you can only see navigation items and dashboards that you have access to.
+You are still allowing everything any user has configured themselves.
-[security]
-use_strict_csp = "1"
-```
+If it is necessary to add extra entries to the CSP header, you can do so by using the `CspPolicyProviderHook` hook,
+read more about it [here](60-Hooks.md#csp-policy-provider). 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 `csp_header` 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.
@@ -159,6 +170,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
@@ -357,7 +379,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..8b8aeaeea7 100644
--- a/doc/60-Hooks.md
+++ b/doc/60-Hooks.md
@@ -47,3 +47,34 @@ class ConfigFormEvents extends ConfigFormEventsHook
}
}
```
+
+## CspPolicyProviderHook
+
+The `CspPolicyProviderHook` allows developers to add custom CSP policies to the Icinga Web 2 frontend.
+It provides the method `getCsp()` which should return an array of CSP policies the module wants to add.
+The policies are combined additively with the default policies, icingaweb2 generated ones and other module-defined
+policies.
+This hook is only called if `csp_enable_modules` is enabled in the Icinga Web 2 configuration.
+
+Hook example:
+
+```php
+namespace Icinga\Module\Acme\ProvidedHook;
+
+use Icinga\Application\Hook\CspPolicyProviderHook;
+use ipl\Web\Common\Csp;
+
+class CspPolicyProvider extends CspPolicyProviderHook
+{
+ public function getCsp(): Csp
+ {
+ $csp = new Csp();
+ $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/CspPolicyProviderHook.php b/library/Icinga/Application/Hook/CspPolicyProviderHook.php
new file mode 100644
index 0000000000..9e9008c19d
--- /dev/null
+++ b/library/Icinga/Application/Hook/CspPolicyProviderHook.php
@@ -0,0 +1,44 @@
+
+// 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 CspPolicyProviderHook
+{
+ /**
+ * Allow the module to provide custom directives and policies for the CSP header.
+ * The return value should be an instance of Csp with the requested policies.
+ *
+ * @return Csp a CSP instance, this instance will be merged with all other requested directives.
+ */
+ abstract public function getCsp(): Csp;
+
+ /**
+ * Get all registered implementations
+ *
+ * @return static[]
+ */
+ public static function all(): array
+ {
+ return Hook::all('CspPolicyProvider');
+ }
+
+ /**
+ * Register the class as a CspPolicyProviderHook 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('CspPolicyProvider', static::class, static::class, true);
+ }
+}
diff --git a/library/Icinga/Security/Csp/LoadedCsp.php b/library/Icinga/Security/Csp/LoadedCsp.php
new file mode 100644
index 0000000000..c9ba26cc80
--- /dev/null
+++ b/library/Icinga/Security/Csp/LoadedCsp.php
@@ -0,0 +1,31 @@
+
+// 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 that has been loaded from a source.
+ * Contains the reason for the CSP directive/policy to exist.
+ */
+class LoadedCsp extends Csp
+{
+ /**
+ * @param CspReason $loadReason the reason for the CSP directive/policy to exist
+ */
+ public function __construct(
+ public readonly CspReason $loadReason,
+ ) {
+ }
+
+ public static function fromCsp(Csp $csp, CspReason $reason): static
+ {
+ $instance = new static($reason);
+ $instance->directives = $csp->directives;
+ return $instance;
+ }
+}
diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php
new file mode 100644
index 0000000000..6fe3f6bdbb
--- /dev/null
+++ b/library/Icinga/Security/Csp/Loader/CspLoader.php
@@ -0,0 +1,22 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Loader;
+
+use Icinga\Security\Csp\LoadedCsp;
+
+/**
+ * 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.
+ *
+ * @return LoadedCsp[]
+ */
+ public function load(): 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..f7e8aa4fad
--- /dev/null
+++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php
@@ -0,0 +1,71 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Loader;
+
+use Icinga\Authentication\Auth;
+use Icinga\Security\Csp\LoadedCsp;
+use Icinga\Security\Csp\Reason\DashboardCspReason;
+use Icinga\Web\Url;
+use Icinga\Web\Widget\Dashboard;
+
+/**
+ * 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
+{
+ /**
+ * Fetches all dashlets for the current user that have an external URL.
+ *
+ * @return LoadedCsp[] A list of CSP directives, one for each dashlet that has an external URL.
+ */
+ public function load(): array
+ {
+ $user = Auth::getInstance()->getUser();
+ if ($user === null) {
+ return [];
+ }
+
+ $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 LoadedCsp(new DashboardCspReason($pane, $dashlet));
+ $csp->add('frame-src', $cspUrl);
+ $result[] = $csp;
+ }
+ }
+
+ 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..6ab0281455
--- /dev/null
+++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php
@@ -0,0 +1,49 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Loader;
+
+use Icinga\Application\ClassLoader;
+use Icinga\Application\Hook\CspPolicyProviderHook;
+use Icinga\Application\Logger;
+use Icinga\Security\Csp\LoadedCsp;
+use Icinga\Security\Csp\Reason\ModuleCspReason;
+use Throwable;
+
+/**
+ * Loads CSP directives from modules.
+ * Modules can implement the {@see CspPolicyProviderHook} 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
+{
+ /**
+ * List all CSP directives from modules.
+ * See {@see CspPolicyProviderHook} for details.
+ *
+ * @return LoadedCsp[]
+ */
+ public function load(): array
+ {
+ $result = [];
+
+ foreach (CspPolicyProviderHook::all() as $hook) {
+ try {
+ $csp = $hook->getCsp();
+ if ($csp->isEmpty()) {
+ continue;
+ }
+ $result[] = LoadedCsp::fromCsp(
+ $csp,
+ new ModuleCspReason(ClassLoader::extractModuleName(get_class($hook))),
+ );
+ } catch (Throwable $e) {
+ Logger::error('Failed to CSP hook on request: %s', $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..90cab3a0d6
--- /dev/null
+++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php
@@ -0,0 +1,81 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Loader;
+
+use Generator;
+use Icinga\Authentication\Auth;
+use Icinga\Security\Csp\LoadedCsp;
+use Icinga\Security\Csp\Reason\NavigationCspReason;
+use Icinga\Web\Navigation\Navigation;
+use Icinga\Web\Navigation\NavigationItem;
+
+/**
+ * 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
+{
+ /**
+ * Fetches navigation items for the current user.
+ *
+ * Iterates through all registered navigation types, loads both user-specific
+ * and shared configurations, and returns a list of menu items.
+ *
+ * @return LoadedCsp[] A list of CSP directives, one for each navigation-item that has an external URL.
+ */
+ public function load(): array
+ {
+ $result = [];
+
+ $auth = Auth::getInstance();
+ if (! $auth->isAuthenticated()) {
+ return [];
+ }
+
+ $navigationTypes = Navigation::getItemTypeConfiguration();
+ foreach ($navigationTypes as $type => $typeConfig) {
+ $navigation = new Navigation();
+ foreach ($navigation->load($type) as $rootItem) {
+ foreach (self::yieldNavigation($rootItem) as $item) {
+ $url = $item->getUrl();
+ $cspUrl = $url->getScheme() . '://' . $url->getHost();
+ if (($port = $url->getPort()) !== null) {
+ $cspUrl .= ':' . $port;
+ }
+
+ $csp = new LoadedCsp(new NavigationCspReason($type, $typeConfig, $item));
+ $csp->add('frame-src', $cspUrl);
+ $result[] = $csp;
+ }
+ }
+ }
+
+ return $result;
+ }
+
+ /**
+ * Recursively yield all navigation items that have an external URL.
+ *
+ * @param NavigationItem $item The top-level navigation item to start from.
+ * @return Generator
+ */
+ protected static function yieldNavigation(NavigationItem $item): Generator
+ {
+ if ($item->hasChildren()) {
+ foreach ($item as $child) {
+ yield from self::yieldNavigation($child);
+ }
+ }
+
+ $url = $item->getUrl();
+ if ($url === null) {
+ return;
+ }
+ if ($item->getTarget() !== '_blank' && $url->isExternal()) {
+ yield $item;
+ }
+ }
+}
diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php
new file mode 100644
index 0000000000..6146ec4db3
--- /dev/null
+++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php
@@ -0,0 +1,37 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Loader;
+
+use Icinga\Security\Csp\LoadedCsp;
+use Icinga\Security\Csp\Reason\StaticCspReason;
+
+/**
+ * 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(): array
+ {
+ $csp = new LoadedCsp(new StaticCspReason($this->name));
+ foreach ($this->directives as $directive => $values) {
+ $csp->add($directive, $values);
+ }
+
+ return [$csp];
+ }
+}
diff --git a/library/Icinga/Security/Csp/Reason/CspReason.php b/library/Icinga/Security/Csp/Reason/CspReason.php
new file mode 100644
index 0000000000..ca9a9ff55f
--- /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.
+ */
+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..62ce62a8fa
--- /dev/null
+++ b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php
@@ -0,0 +1,26 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Reason;
+
+use Icinga\Web\Widget\Dashboard\Dashlet;
+use Icinga\Web\Widget\Dashboard\Pane;
+
+/**
+ * Reason for loading a CSP directive for a dashboard dashlet.
+ * The CSP directive allows the iframe to be embedded on the page.
+ */
+readonly class DashboardCspReason implements CspReason
+{
+ /**
+ * @param Pane $pane the pane that contains the dashlet
+ * @param Dashlet $dashlet the dashlet to load the CSP directive for
+ */
+ public function __construct(
+ 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..3e16b3ecb9
--- /dev/null
+++ b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php
@@ -0,0 +1,21 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Reason;
+
+/**
+ * Reason for loading a CSP directive for a module.
+ * The CSP directive allows the module to be loaded.
+ */
+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..5ac8576dc1
--- /dev/null
+++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php
@@ -0,0 +1,27 @@
+
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+namespace Icinga\Security\Csp\Reason;
+
+use Icinga\Web\Navigation\NavigationItem;
+
+/**
+ * Reason for loading a CSP directive for a navigation item.
+ * The CSP directive allows the iframe to be embedded on the page.
+ */
+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 NavigationItem $item the navigation item to load the CSP directive for
+ */
+ public function __construct(
+ public string $type,
+ public array $typeConfiguration,
+ public NavigationItem $item,
+ ) {
+ }
+}
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..ec79d90d48 100644
--- a/library/Icinga/Util/Csp.php
+++ b/library/Icinga/Util/Csp.php
@@ -5,12 +5,21 @@
namespace Icinga\Util;
+use Exception;
+use Icinga\Application\Config;
+use Icinga\Application\Icinga;
+use Icinga\Application\Logger;
+use Icinga\Data\ConfigObject;
+use Icinga\Security\Csp\LoadedCsp;
+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 +33,8 @@
*/
class Csp
{
- /** @var static */
- protected static $instance;
-
- /** @var ?string */
- protected $styleNonce;
+ /** @var CspInstance|null */
+ protected static ?CspInstance $csp = null;
/** Singleton */
private function __construct()
@@ -36,7 +42,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 +52,144 @@ private function __construct()
*/
public static function addHeader(Response $response): void
{
- $csp = static::getInstance();
+ $response->setHeader('Content-Security-Policy', static::getHeader(), true);
+ }
+
+ public static function isEnabled(): bool
+ {
+ return (bool) Config::app()->get('security', 'use_strict_csp', '0');
+ }
+
+ /**
+ * @return LoadedCsp[]
+ */
+ public static function load(?ConfigObject $config = null): array
+ {
+ if ($config === null) {
+ $config = Config::app()->getSection('security');
+ }
- if (empty($csp->styleNonce)) {
+ $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
- );
+ $result = [];
+ $result = array_merge($result, (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());
+
+ try {
+ if ($config->get('csp_enable_modules', '1')) {
+ $result = array_merge($result, (new ModuleCspLoader())->load());
+ }
+ } catch (Exception $e) {
+ Logger::warning('Module CSP loader failed: %s', $e->getMessage());
+ }
+
+ try {
+ if ($config->get('csp_enable_dashboards', '1')) {
+ $result = array_merge($result, (new DashboardCspLoader())->load());
+ }
+ } catch (Exception $e) {
+ Logger::warning('Dashboard CSP loader failed: %s', $e->getMessage());
+ }
+
+ try {
+ if ($config->get('csp_enable_navigation', '1')) {
+ $result = array_merge($result, (new NavigationCspLoader())->load());
+ }
+ } catch (Exception $e) {
+ Logger::warning('Navigation CSP loader failed: %s', $e->getMessage());
+ }
+
+ return $result;
}
/**
- * 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')) {
+ 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 self
+ * @return CspInstance Returns the generated header value.
+ * @throws RuntimeException If no nonce set for CSS
*/
- protected static function getInstance(): self
+ public 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)
- )
- );
- }
+ $csps = self::load();
+ return CspInstance::merge(...$csps);
+ }
- $csp->styleNonce = $nonce;
+ /**
+ * 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);
+ }
+ }
- static::$instance = $csp;
+ /**
+ * 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 dc18f98385..83ca9049ec 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..0141bebee6
--- /dev/null
+++ b/public/css/icinga/csp-config-editor.less
@@ -0,0 +1,152 @@
+/*! Icinga Web 2 | (c) 2026 Icinga Development Team | GPLv2+ */
+
+// 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-policies {
+ display: flex;
+ flex-direction: row;
+ justify-content: end;
+ gap: 0.25em;
+ }
+
+ .csp-policy-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-policies .icon) {
+ .csp-policies: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;
+ }
+}