mirror of
https://github.com/Icinga/icingaweb2.git
synced 2026-06-10 17:11:16 -04:00
667 lines
23 KiB
PHP
667 lines
23 KiB
PHP
<?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',
|
|
],
|
|
);
|
|
|
|
$disabledState = $this->getPopulatedValue('use_custom_csp') === '1';
|
|
|
|
$formHintClassList = ['csp-form-hint'];
|
|
if ($disabledState) {
|
|
$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),
|
|
]);
|
|
},
|
|
! $disabledState,
|
|
$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',
|
|
! $disabledState,
|
|
);
|
|
|
|
$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),
|
|
]);
|
|
},
|
|
$disabledState === 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',
|
|
! $disabledState,
|
|
);
|
|
|
|
$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),
|
|
]);
|
|
},
|
|
$disabledState === 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',
|
|
! $disabledState,
|
|
);
|
|
|
|
$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);
|
|
}
|
|
return Table::tr([
|
|
Table::td($reason->typeConfiguration['label'] ?? $reason->type),
|
|
$parentCell,
|
|
Table::td($reason->name),
|
|
Table::td([
|
|
match ($reason->isShared) {
|
|
true => new Icon('share', [
|
|
'class' => 'shared-item',
|
|
'title' => $this->translate('Shared item. Displayed user is owner.'),
|
|
]),
|
|
false => null,
|
|
},
|
|
$reason->username,
|
|
]),
|
|
Table::td($directive),
|
|
$this->buildExpression($directive, $expression),
|
|
]);
|
|
},
|
|
$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' => '',
|
|
'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', '');
|
|
|
|
$this->changed = ! empty(array_diff_assoc(
|
|
iterator_to_array($section),
|
|
iterator_to_array($beforeSection)
|
|
));
|
|
|
|
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']);
|
|
}
|
|
}
|