mirror of
https://github.com/Icinga/icingaweb2.git
synced 2026-06-08 16:12:43 -04:00
Merge 7fa22c57a4 into 40114d90e4
This commit is contained in:
commit
4ac4cee0eb
26 changed files with 1784 additions and 94 deletions
|
|
@ -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\Exception\NotFoundError;
|
|||
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\Controller;
|
|||
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 @@ class ConfigController extends Controller
|
|||
'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 @@ class ConfigController extends Controller
|
|||
{
|
||||
if ($this->hasPermission('config/general')) {
|
||||
$this->redirectNow('config/general');
|
||||
} elseif ($this->hasPermission('config/security')) {
|
||||
$this->redirectNow('config/security');
|
||||
} elseif ($this->hasPermission('config/resources')) {
|
||||
$this->redirectNow('config/resource');
|
||||
} elseif ($this->hasPermission('config/access-control/*')) {
|
||||
|
|
@ -96,26 +109,52 @@ class ConfigController extends Controller
|
|||
public function generalAction()
|
||||
{
|
||||
$this->assertPermission('config/general');
|
||||
|
||||
$this->view->title = $this->translate('General');
|
||||
|
||||
$form = new GeneralConfigForm();
|
||||
$form->setIniConfig(Config::app());
|
||||
$form->setOnSuccess(function (GeneralConfigForm $form) {
|
||||
$config = Config::app();
|
||||
$useStrictCsp = (bool) $config->get('security', 'use_strict_csp', false);
|
||||
if ($form->onSuccess() === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$appConfigForm = $form->getSubForm('form_config_general_application');
|
||||
if ($appConfigForm && (bool) $appConfigForm->getValue('security_use_strict_csp') !== $useStrictCsp) {
|
||||
$this->getResponse()->setReloadWindow(true);
|
||||
}
|
||||
})->handleRequest();
|
||||
$form->handleRequest();
|
||||
|
||||
$this->view->form = $form;
|
||||
$this->view->title = $this->translate('General');
|
||||
|
||||
$this->createApplicationTabs()->activate('general');
|
||||
}
|
||||
|
||||
/**
|
||||
* Security configuration
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
Notification::success($this->translate('Content-Security-Policy updated'));
|
||||
});
|
||||
$cspForm->handleRequest(ServerRequest::fromGlobals());
|
||||
$this->view->cspForm = $cspForm;
|
||||
|
||||
$this->createApplicationTabs()->activate('security');
|
||||
}
|
||||
|
||||
/**
|
||||
* Display the list of all modules
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -57,18 +57,6 @@ class ApplicationConfigForm extends Form
|
|||
)
|
||||
);
|
||||
|
||||
$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',
|
||||
|
|
|
|||
674
application/forms/Config/Security/CspConfigForm.php
Normal file
674
application/forms/Config/Security/CspConfigForm.php
Normal file
|
|
@ -0,0 +1,674 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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']);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 @@ class DashletForm extends Form
|
|||
)
|
||||
);
|
||||
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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 @@ class NavigationItemForm extends Form
|
|||
)
|
||||
);
|
||||
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -548,6 +548,9 @@ class RoleForm extends RepositoryForm
|
|||
'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')
|
||||
],
|
||||
|
|
|
|||
7
application/views/scripts/config/security.phtml
Normal file
7
application/views/scripts/config/security.phtml
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<div class="controls">
|
||||
<?= $tabs ?>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2><?= $this->translate('Content Security Policy') ?></h2>
|
||||
<?= $cspForm ?>
|
||||
</div>
|
||||
|
|
@ -41,19 +41,6 @@ config_resource = "icingaweb_db"
|
|||
module_path = "/usr/share/icingaweb2/modules"
|
||||
```
|
||||
|
||||
### Security Configuration <a id="configuration-general-security"></a>
|
||||
|
||||
| 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 <a id="configuration-general-logging"></a>
|
||||
|
||||
Option | Description
|
||||
|
|
@ -87,3 +74,32 @@ Example:
|
|||
disabled = "1"
|
||||
default = "high-contrast"
|
||||
```
|
||||
|
||||
## Security Configuration <a id="configuration-security"></a>
|
||||
|
||||
Navigate into **Configuration > Application > Security**.
|
||||
|
||||
This configuration is stored in the `config.ini` file in `/etc/icingaweb2`.
|
||||
|
||||
### Content Security Policy Configuration <a id="configuration-security-csp"></a>
|
||||
|
||||
| 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"
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 <a id="advanced-topics-authentication-tips"></a>
|
||||
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -47,3 +47,33 @@ class ConfigFormEvents extends ConfigFormEventsHook
|
|||
}
|
||||
}
|
||||
```
|
||||
|
||||
## CspHook <a id="hooks-csp"></a>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
|
|||
47
library/Icinga/Application/Hook/CspHook.php
Normal file
47
library/Icinga/Application/Hook/CspHook.php
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
21
library/Icinga/Security/Csp/AttributedCsp.php
Normal file
21
library/Icinga/Security/Csp/AttributedCsp.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
25
library/Icinga/Security/Csp/Loader/CspLoader.php
Normal file
25
library/Icinga/Security/Csp/Loader/CspLoader.php
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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;
|
||||
}
|
||||
99
library/Icinga/Security/Csp/Loader/DashboardCspLoader.php
Normal file
99
library/Icinga/Security/Csp/Loader/DashboardCspLoader.php
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
43
library/Icinga/Security/Csp/Loader/ModuleCspLoader.php
Normal file
43
library/Icinga/Security/Csp/Loader/ModuleCspLoader.php
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
187
library/Icinga/Security/Csp/Loader/NavigationCspLoader.php
Normal file
187
library/Icinga/Security/Csp/Loader/NavigationCspLoader.php
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
38
library/Icinga/Security/Csp/Loader/StaticCspLoader.php
Normal file
38
library/Icinga/Security/Csp/Loader/StaticCspLoader.php
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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))];
|
||||
}
|
||||
}
|
||||
14
library/Icinga/Security/Csp/Reason/CspReason.php
Normal file
14
library/Icinga/Security/Csp/Reason/CspReason.php
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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
|
||||
{
|
||||
}
|
||||
28
library/Icinga/Security/Csp/Reason/DashboardCspReason.php
Normal file
28
library/Icinga/Security/Csp/Reason/DashboardCspReason.php
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
20
library/Icinga/Security/Csp/Reason/ModuleCspReason.php
Normal file
20
library/Icinga/Security/Csp/Reason/ModuleCspReason.php
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
31
library/Icinga/Security/Csp/Reason/NavigationCspReason.php
Normal file
31
library/Icinga/Security/Csp/Reason/NavigationCspReason.php
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
21
library/Icinga/Security/Csp/Reason/StaticCspReason.php
Normal file
21
library/Icinga/Security/Csp/Reason/StaticCspReason.php
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<?php
|
||||
|
||||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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,
|
||||
) {
|
||||
}
|
||||
}
|
||||
|
|
@ -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 @@ use function ipl\Stdlib\get_php_type;
|
|||
*/
|
||||
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 @@ class Csp
|
|||
}
|
||||
|
||||
/**
|
||||
* 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,17 +51,176 @@ class Csp
|
|||
*/
|
||||
public static function addHeader(Response $response): void
|
||||
{
|
||||
$csp = static::getInstance();
|
||||
$response->setHeader('Content-Security-Policy', static::getHeader(), true);
|
||||
}
|
||||
|
||||
if (empty($csp->styleNonce)) {
|
||||
/**
|
||||
* 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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Content-Security-Policy header.
|
||||
*
|
||||
* @return string Returns the CSP header for this request.
|
||||
* @throws RuntimeException If no nonce set for CSS
|
||||
*/
|
||||
public static function getHeader(): string
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
return static::$csp->getHeader();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 CspInstance Returns the custom CSP header.
|
||||
*/
|
||||
protected static function getCustomHeader(): CspInstance
|
||||
{
|
||||
$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 automatically generated Content-Security-Policy
|
||||
*
|
||||
* @return CspInstance Returns the generated header value.
|
||||
*
|
||||
* @throws RuntimeException If no nonce set for CSS
|
||||
*/
|
||||
protected static function getAutomaticHeader(): CspInstance
|
||||
{
|
||||
$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());
|
||||
}
|
||||
|
||||
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());
|
||||
}
|
||||
|
||||
$csps = array_map(fn (AttributedCsp $csp) => $csp->csp, $attributedCsps);
|
||||
|
||||
return CspInstance::merge(...$csps);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -67,10 +231,10 @@ class Csp
|
|||
*/
|
||||
public static function createNonce(): void
|
||||
{
|
||||
$csp = static::getInstance();
|
||||
$csp->styleNonce = base64_encode(random_bytes(16));
|
||||
|
||||
Window::getInstance()->getSessionNamespace('csp')->set('style_nonce', $csp->styleNonce);
|
||||
if (Window::getInstance()->getSessionNamespace('csp')->get('style_nonce') === null) {
|
||||
$nonce = base64_encode(random_bytes(16));
|
||||
Window::getInstance()->getSessionNamespace('csp')->set('style_nonce', $nonce);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -80,33 +244,10 @@ class Csp
|
|||
*/
|
||||
public static function getStyleNonce(): ?string
|
||||
{
|
||||
return static::getInstance()->styleNonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the CSP instance
|
||||
*
|
||||
* @return self
|
||||
*/
|
||||
protected static function getInstance(): self
|
||||
{
|
||||
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)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
$csp->styleNonce = $nonce;
|
||||
|
||||
static::$instance = $csp;
|
||||
if (Icinga::app()->isWeb() && static::$csp !== null) {
|
||||
return static::$csp->getNonce();
|
||||
}
|
||||
|
||||
return static::$instance;
|
||||
return Window::getInstance()->getSessionNamespace('csp')->get('style_nonce');
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -383,7 +383,7 @@ class Response extends Zend_Controller_Response_Http
|
|||
$this->setRedirect($redirectUrl->getAbsoluteUrl());
|
||||
}
|
||||
|
||||
if (Csp::getStyleNonce() && Config::app()->get('security', 'use_strict_csp', false)) {
|
||||
if (Csp::getStyleNonce() && Csp::isEnabled()) {
|
||||
Csp::addHeader($this);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
153
public/css/icinga/csp-config-editor.less
Normal file
153
public/css/icinga/csp-config-editor.less
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue