icingaweb2/application/forms/Config/Security/CspConfigForm.php
2026-05-19 13:36:12 +02:00

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']);
}
}