From c3cb7f63471a6c984737a501b46062974168fb1f Mon Sep 17 00:00:00 2001 From: William Calliari <42240136+w1ll-i-code@users.noreply.github.com> Date: Thu, 15 May 2025 12:10:05 +0200 Subject: [PATCH 01/96] Allow modules to adjust the CSP headers through a dedicated hook. --- .../Config/General/ApplicationConfigForm.php | 9 ++ .../Application/Hook/CspDirectiveHook.php | 18 +++ library/Icinga/Util/Csp.php | 143 ++++++++++++++++-- library/Icinga/Util/NavigationItemHelper.php | 78 ++++++++++ 4 files changed, 239 insertions(+), 9 deletions(-) create mode 100644 library/Icinga/Application/Hook/CspDirectiveHook.php create mode 100644 library/Icinga/Util/NavigationItemHelper.php diff --git a/application/forms/Config/General/ApplicationConfigForm.php b/application/forms/Config/General/ApplicationConfigForm.php index 96c6a860ca..3e2391697f 100644 --- a/application/forms/Config/General/ApplicationConfigForm.php +++ b/application/forms/Config/General/ApplicationConfigForm.php @@ -6,8 +6,10 @@ namespace Icinga\Forms\Config\General; use Icinga\Application\Icinga; +use Icinga\Authentication\Auth; use Icinga\Data\ResourceFactory; use Icinga\Web\Form; +use Icinga\Util\Csp; /** * Configuration form for general application options @@ -62,6 +64,7 @@ public function createElements(array $formData) 'security_use_strict_csp', [ 'label' => $this->translate('Enable strict content security policy'), + 'autosubmit' => true, 'description' => $this->translate( 'Set whether to use strict content security policy (CSP).' . ' This setting helps to protect from cross-site scripting (XSS).' @@ -69,6 +72,12 @@ public function createElements(array $formData) ] ); + if ($formData['security_use_strict_csp']) { + Csp::createNonce(); + $header = Csp::getContentSecurityPolicy(Auth::getInstance()->getUser()); + $this->addHint("Content-Security-Policy: $header"); + } + $this->addElement( 'text', 'global_module_path', diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspDirectiveHook.php new file mode 100644 index 0000000000..43eb6c26eb --- /dev/null +++ b/library/Icinga/Application/Hook/CspDirectiveHook.php @@ -0,0 +1,18 @@ + [ 'https://*.media.tumblr.com', 'https://http.cat/' ] ] + * + * @return array The CSP directives are the keys and the policies the values. + */ + abstract public function getCspDirectives(): array; +} diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index d5fbdfd52a..69907f28ee 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -5,6 +5,13 @@ namespace Icinga\Util; +use Icinga\Application\Hook; +use Icinga\Application\Hook\CspDirectiveHook; +use Icinga\Application\Icinga; +use Icinga\Application\Logger; +use Icinga\Authentication\Auth; +use Icinga\Data\ConfigObject; +use Icinga\User; use Icinga\Web\Response; use Icinga\Web\Window; use RuntimeException; @@ -46,17 +53,131 @@ private function __construct() */ public static function addHeader(Response $response): void { + $user = Auth::getInstance()->getUser(); + $header = static::getContentSecurityPolicy($user); + Logger::debug("Setting Content-Security-Policy header for user {$user->getUsername()} to $header"); + $response->setHeader('Content-Security-Policy', $header, true); + } + + /** + * Get the Content-Security-Policy for a specific user. + * + * @param User $user + * + * @throws RuntimeException If no nonce set for CSS + * + * @return string Returns the generated header value. + */ + public static function getContentSecurityPolicy(User $user): string { $csp = static::getInstance(); if (empty($csp->styleNonce)) { throw new RuntimeException('No nonce set for CSS'); } - $response->setHeader( - 'Content-Security-Policy', - "script-src 'self'; style-src 'self' 'nonce-$csp->styleNonce';", - true - ); + // These are the default directives that should always be enforced. 'self' is valid for all + // directives and will therefor not be listed here. + $cspDirectives = [ + 'style-src' => ["'nonce-{$csp->styleNonce}'"], + 'font-src' => ["data:"], + 'img-src' => ["data:"], + 'frame-src' => [] + ]; + + // Whitelist the hosts in the custom NavigationItems configured for the user, + // so that the iframes can be rendered properly. + /** @var array $navigationItems */ + $navigationItems = NavigationItemHelper::fetchUserNavigationItems($user); + foreach ($navigationItems as $navigationItem) { + + // Skip the host if the link gets opened in a new window. + if ($navigationItem->get("target", "") === "_blank") { + continue; + } + + $name = $navigationItem->get("name", ""); + $url = $navigationItem->get("url", ""); + + $scheme = parse_url($url, PHP_URL_SCHEME); + $host = parse_url($url, PHP_URL_HOST); + + if ($host === null || !static::validateCspPolicy("NavigationItem '$name'", "frame-src", $host)) { + continue; + } + + $policy = $host; + if ($scheme !== null) { + $policy = "$scheme://$host"; + } + + $cspDirectives['frame-src'][] = $policy; + } + + // Allow modules to add their own csp directives in a limited fashion. + /** @var CspDirectiveHook $hook */ + foreach (Hook::all('CspDirective') as $hook) { + foreach ($hook->getCspDirectives() as $directive => $policies) { + + // policy names contain only lowercase letters and '-'. Reject anything else. + if (!preg_match('|^[a-z\-]+$|', $directive)) { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Invalid CSP directive found: $directive"); + continue; + } + + // The default-src can only ever be 'self'. Disallow any updates to it. + if ($directive === "default-src") { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Changing default-src is forbidden."); + continue; + } + + $cspDirectives[$directive] = $cspDirectives[$directive] ?? []; + foreach ($policies as $policy) { + $source = get_class($hook); + if (!static::validateCspPolicy($source, $directive, $policy)) { + continue; + } + + $cspDirectives[$directive][] = $policy; + } + } + } + + $header = "default-src 'self'; "; + foreach ($cspDirectives as $directive => $policies) { + if (!empty($policies)) { + $header .= ' ' . implode(' ', array_merge([$directive, "'self'"], array_unique($policies))) . ';'; + } + } + + return $header; + } + + public static function validateCspPolicy(string $source, string $directive, string $policy): bool { + // We accept the following policies: + // 1. Hosts: Modules can whitelist certain domains as sources for the CSP header directives. + // - A host can have a specific scheme (http or https). + // - A host can whitelist all subdomains with * + // - A host can contain all alphanumeric characters as well as '+', '-', '_', '.', and ':' + // 2. Nonce: Modules are allowed to specify custom nonce for some directives. + // - A nonce is enclosed in single-quotes: "'" + // - A nonce begins with 'nonce-' followed by at least 22 significant characters of base64 encoded data. + // as recommended by the standard: https://content-security-policy.com/nonce/ + if (! preg_match("/^((https?:\/\/)?\*?[a-zA-Z0-9+._\-:]+|'nonce-[a-zA-Z0-9+\/]{22,}={0,3}')$/", $policy)) { + Logger::debug("$source: Invalid CSP policy found: $directive $policy"); + return false; + } + + // We refuse all overly aggressive whitelisting by default. This includes: + // 1. Whitelisting all Hosts with '*' + // 2. Whitelisting all Hosts in a tld, e.g. 'http://*.com' + if (preg_match('|\*(\.[a-zA-Z]+)?$|', $directive)) { + Logger::debug("$source: Disallowing whitelisting all hosts. $directive"); + return false; + } + + return true; } /** @@ -68,9 +189,10 @@ public static function addHeader(Response $response): void 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 ($csp->styleNonce === null) { + $csp->styleNonce = base64_encode(random_bytes(16)); + Window::getInstance()->getSessionNamespace('csp')->set('style_nonce', $csp->styleNonce); + } } /** @@ -80,7 +202,10 @@ public static function createNonce(): void */ public static function getStyleNonce(): ?string { - return static::getInstance()->styleNonce; + if (Icinga::app()->isWeb()) { + return static::getInstance()->styleNonce; + } + return null; } /** diff --git a/library/Icinga/Util/NavigationItemHelper.php b/library/Icinga/Util/NavigationItemHelper.php new file mode 100644 index 0000000000..c3cdb4c63f --- /dev/null +++ b/library/Icinga/Util/NavigationItemHelper.php @@ -0,0 +1,78 @@ +getUsername(); + self::$navigationItemCache = array_merge( + static::fetchSharedNavigationItemConfigs($itemTypeConfig, $username), + static::fetchUserNavigationItemConfigs($itemTypeConfig, $username) + ); + + return self::$navigationItemCache; + } + + /** + * Return all shared navigation item configurations + * + * @param string $owner A username if only items shared by a specific user are desired + * + * @return array + */ + protected static function fetchSharedNavigationItemConfigs($itemTypeConfig, string $owner) + { + $configs = array(); + foreach ($itemTypeConfig as $type => $_) { + $config = Config::navigation($type); + $config->getConfigObject()->setKeyColumn('name'); + $query = $config->select(); + if ($owner !== null) { + $query->applyFilter(new FilterMatchCaseInsensitive('owner', '=', $owner)); + } + + foreach ($query as $itemConfig) { + $configs[] = $itemConfig; + } + } + + return $configs; + } + + /** + * Return all user navigation item configurations + * + * @param string $username + * + * @return array + */ + protected static function fetchUserNavigationItemConfigs($itemTypeConfig, string $username) + { + $configs = array(); + foreach ($itemTypeConfig as $type => $_) { + $config = Config::navigation($type, $username); + $config->getConfigObject()->setKeyColumn('name'); + foreach ($config->select() as $itemConfig) { + $configs[] = $itemConfig; + } + } + + return $configs; + } + +} \ No newline at end of file From 8f8652f7b94c8610cb6d2943c54a38b4c9dd615e Mon Sep 17 00:00:00 2001 From: William Calliari <42240136+w1ll-i-code@users.noreply.github.com> Date: Mon, 19 May 2025 15:18:04 +0200 Subject: [PATCH 02/96] Add additional validation for the url before using it in the frame-src scp header --- library/Icinga/Util/Csp.php | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 69907f28ee..25879031c4 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -86,7 +86,7 @@ public static function getContentSecurityPolicy(User $user): string { // Whitelist the hosts in the custom NavigationItems configured for the user, // so that the iframes can be rendered properly. - /** @var array $navigationItems */ + /** @var ConfigObject[] $navigationItems */ $navigationItems = NavigationItemHelper::fetchUserNavigationItems($user); foreach ($navigationItems as $navigationItem) { @@ -96,12 +96,19 @@ public static function getContentSecurityPolicy(User $user): string { } $name = $navigationItem->get("name", ""); + $errorSource = "NavigationItem '$name'"; $url = $navigationItem->get("url", ""); + // Make sure $url is actually valid; + if (filter_var($url, FILTER_VALIDATE_URL) === false) { + Logger::debug("$errorSource: Skipping invalid url: $host"); + continue; + } + $scheme = parse_url($url, PHP_URL_SCHEME); $host = parse_url($url, PHP_URL_HOST); - if ($host === null || !static::validateCspPolicy("NavigationItem '$name'", "frame-src", $host)) { + if ($host === null || !static::validateCspPolicy($errorSource, "frame-src", $host)) { continue; } From 364de2dcc3818b3bf01d7e88b36cc1da64217342 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 12:48:37 +0100 Subject: [PATCH 03/96] Allow editing of the CSP trusted image sources Co-authored-by: Davide Zeni --- library/Icinga/Util/Csp.php | 116 ++++++++++++++++--- library/Icinga/Util/NavigationItemHelper.php | 78 ------------- 2 files changed, 97 insertions(+), 97 deletions(-) delete mode 100644 library/Icinga/Util/NavigationItemHelper.php diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 25879031c4..54c96fab7f 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -14,7 +14,10 @@ use Icinga\User; use Icinga\Web\Response; use Icinga\Web\Window; +use Icinga\Application\Config; use RuntimeException; +use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Widget\Dashboard; use function ipl\Stdlib\get_php_type; @@ -54,7 +57,7 @@ private function __construct() public static function addHeader(Response $response): void { $user = Auth::getInstance()->getUser(); - $header = static::getContentSecurityPolicy($user); + $header = static::getContentSecurityPolicy(); Logger::debug("Setting Content-Security-Policy header for user {$user->getUsername()} to $header"); $response->setHeader('Content-Security-Policy', $header, true); } @@ -68,7 +71,8 @@ public static function addHeader(Response $response): void * * @return string Returns the generated header value. */ - public static function getContentSecurityPolicy(User $user): string { + public static function getContentSecurityPolicy(): string + { $csp = static::getInstance(); if (empty($csp->styleNonce)) { @@ -87,26 +91,19 @@ public static function getContentSecurityPolicy(User $user): string { // Whitelist the hosts in the custom NavigationItems configured for the user, // so that the iframes can be rendered properly. /** @var ConfigObject[] $navigationItems */ - $navigationItems = NavigationItemHelper::fetchUserNavigationItems($user); + $navigationItems = self::fetchDashletNavigationItemConfigs(); foreach ($navigationItems as $navigationItem) { + $errorSource = sprintf("Navigation item %s", $navigationItem['name']); - // Skip the host if the link gets opened in a new window. - if ($navigationItem->get("target", "") === "_blank") { - continue; - } - - $name = $navigationItem->get("name", ""); - $errorSource = "NavigationItem '$name'"; - $url = $navigationItem->get("url", ""); - + $host = parse_url($navigationItem["url"], PHP_URL_HOST); // Make sure $url is actually valid; - if (filter_var($url, FILTER_VALIDATE_URL) === false) { + if (filter_var($navigationItem["url"], FILTER_VALIDATE_URL) === false) { Logger::debug("$errorSource: Skipping invalid url: $host"); continue; } - $scheme = parse_url($url, PHP_URL_SCHEME); - $host = parse_url($url, PHP_URL_HOST); + $scheme = parse_url($navigationItem["url"], PHP_URL_SCHEME); + if ($host === null || !static::validateCspPolicy($errorSource, "frame-src", $host)) { continue; @@ -119,12 +116,10 @@ public static function getContentSecurityPolicy(User $user): string { $cspDirectives['frame-src'][] = $policy; } - // Allow modules to add their own csp directives in a limited fashion. /** @var CspDirectiveHook $hook */ foreach (Hook::all('CspDirective') as $hook) { foreach ($hook->getCspDirectives() as $directive => $policies) { - // policy names contain only lowercase letters and '-'. Reject anything else. if (!preg_match('|^[a-z\-]+$|', $directive)) { $errorSource = get_class($hook); @@ -157,11 +152,12 @@ public static function getContentSecurityPolicy(User $user): string { $header .= ' ' . implode(' ', array_merge([$directive, "'self'"], array_unique($policies))) . ';'; } } - + return $header; } - public static function validateCspPolicy(string $source, string $directive, string $policy): bool { + public static function validateCspPolicy(string $source, string $directive, string $policy): bool + { // We accept the following policies: // 1. Hosts: Modules can whitelist certain domains as sources for the CSP header directives. // - A host can have a specific scheme (http or https). @@ -241,4 +237,86 @@ protected static function getInstance(): self return static::$instance; } + + + /** + * Fetches and merges configurations for navigation menu items and dashlets. + * + * @return array An array containing both navigation items and dashlet configurations. + * // returns [['name' => 'Item Name', 'url' => 'https://example.com'], ...] + */ + protected static function fetchDashletNavigationItemConfigs() + { + return array_merge( + self::fetchNavigationItems(), + self::fetchDashletsItems() + ); + } + + /** + * Fetches navigation items for the current user. + * + * Iterates through all registered navigation types, loads both user-specific + * and shared configurations, and returns a list of menu items. + * + * @return array Each item is an associative array with 'name' and 'url' keys. + * Example: [ ['name' => 'Home', 'url' => '/'], ['name' => 'Profile', 'url' => '/profile'] ] + */ + protected static function fetchNavigationItems() + { + $username = Auth::getInstance()->getUser()->getUsername(); + $navigationType = Navigation::getItemTypeConfiguration(); + foreach ($navigationType as $type => $_) { + $config = Config::navigation($type, $username); + $config->getConfigObject()->setKeyColumn('name'); + $configShared = Config::navigation($type); + $configShared->getConfigObject()->setKeyColumn('name'); + foreach ($config->select() as $itemConfig) { + $menuItems[] = ["name" => $itemConfig->get('name'), "url" => $itemConfig->get('url')]; + } + foreach ($configShared->select() as $itemConfig) { + $menuItems[] = ["name" => $itemConfig->get('name'), "url" => $itemConfig->get('url')]; + } + } + return $menuItems; + } + + /** + * Fetches all dashlets for the current user that have an external URL. + * + * @return array A list of dashlets with their names and absolute URLs. + * // returns [['name' => 'Dashlet Name', 'url' => 'https://external.dashlet.com'], ...] + */ + protected static function fetchDashletsItems() + { + $dashboard = new Dashboard(); + $dashboard->setUser(Auth::getInstance()->getUser()); + $dashboard->load(); + $dashlets = []; + foreach ($dashboard->getPanes() as $pane) { + foreach ($pane->getDashlets() as $dashlet) { + $url = $dashlet->getUrl(); + // Prefer explicit external URL parameter if present + $externalUrl = $url->getParam("url"); + if ($externalUrl !== null) { + $dashlets[] = [ + "name" => $dashlet->getName(), + "url" => $externalUrl + ]; + continue; + } + + // Otherwise, check if the dashlet URL itself is external + if ($url->isExternal()) { + $dashlets[] = [ + "name" => $dashlet->getName(), + "url" => $url->getAbsoluteUrl() + ]; + } + } + } + + return $dashlets; + } + } diff --git a/library/Icinga/Util/NavigationItemHelper.php b/library/Icinga/Util/NavigationItemHelper.php deleted file mode 100644 index c3cdb4c63f..0000000000 --- a/library/Icinga/Util/NavigationItemHelper.php +++ /dev/null @@ -1,78 +0,0 @@ -getUsername(); - self::$navigationItemCache = array_merge( - static::fetchSharedNavigationItemConfigs($itemTypeConfig, $username), - static::fetchUserNavigationItemConfigs($itemTypeConfig, $username) - ); - - return self::$navigationItemCache; - } - - /** - * Return all shared navigation item configurations - * - * @param string $owner A username if only items shared by a specific user are desired - * - * @return array - */ - protected static function fetchSharedNavigationItemConfigs($itemTypeConfig, string $owner) - { - $configs = array(); - foreach ($itemTypeConfig as $type => $_) { - $config = Config::navigation($type); - $config->getConfigObject()->setKeyColumn('name'); - $query = $config->select(); - if ($owner !== null) { - $query->applyFilter(new FilterMatchCaseInsensitive('owner', '=', $owner)); - } - - foreach ($query as $itemConfig) { - $configs[] = $itemConfig; - } - } - - return $configs; - } - - /** - * Return all user navigation item configurations - * - * @param string $username - * - * @return array - */ - protected static function fetchUserNavigationItemConfigs($itemTypeConfig, string $username) - { - $configs = array(); - foreach ($itemTypeConfig as $type => $_) { - $config = Config::navigation($type, $username); - $config->getConfigObject()->setKeyColumn('name'); - foreach ($config->select() as $itemConfig) { - $configs[] = $itemConfig; - } - } - - return $configs; - } - -} \ No newline at end of file From d078935a3418173bc8a103030f78af456bab708b Mon Sep 17 00:00:00 2001 From: Davide Zeni Date: Mon, 25 Aug 2025 13:47:52 +0200 Subject: [PATCH 04/96] Refactor CSP validation logic and improve access control for shared navigation items --- library/Icinga/Util/Csp.php | 43 ++++++------------------------------- 1 file changed, 7 insertions(+), 36 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 54c96fab7f..e307477743 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -105,7 +105,7 @@ public static function getContentSecurityPolicy(): string $scheme = parse_url($navigationItem["url"], PHP_URL_SCHEME); - if ($host === null || !static::validateCspPolicy($errorSource, "frame-src", $host)) { + if ($host === null) { continue; } @@ -136,11 +136,6 @@ public static function getContentSecurityPolicy(): string $cspDirectives[$directive] = $cspDirectives[$directive] ?? []; foreach ($policies as $policy) { - $source = get_class($hook); - if (!static::validateCspPolicy($source, $directive, $policy)) { - continue; - } - $cspDirectives[$directive][] = $policy; } } @@ -155,34 +150,6 @@ public static function getContentSecurityPolicy(): string return $header; } - - public static function validateCspPolicy(string $source, string $directive, string $policy): bool - { - // We accept the following policies: - // 1. Hosts: Modules can whitelist certain domains as sources for the CSP header directives. - // - A host can have a specific scheme (http or https). - // - A host can whitelist all subdomains with * - // - A host can contain all alphanumeric characters as well as '+', '-', '_', '.', and ':' - // 2. Nonce: Modules are allowed to specify custom nonce for some directives. - // - A nonce is enclosed in single-quotes: "'" - // - A nonce begins with 'nonce-' followed by at least 22 significant characters of base64 encoded data. - // as recommended by the standard: https://content-security-policy.com/nonce/ - if (! preg_match("/^((https?:\/\/)?\*?[a-zA-Z0-9+._\-:]+|'nonce-[a-zA-Z0-9+\/]{22,}={0,3}')$/", $policy)) { - Logger::debug("$source: Invalid CSP policy found: $directive $policy"); - return false; - } - - // We refuse all overly aggressive whitelisting by default. This includes: - // 1. Whitelisting all Hosts with '*' - // 2. Whitelisting all Hosts in a tld, e.g. 'http://*.com' - if (preg_match('|\*(\.[a-zA-Z]+)?$|', $directive)) { - Logger::debug("$source: Disallowing whitelisting all hosts. $directive"); - return false; - } - - return true; - } - /** * Set/recreate nonce for dynamic CSS * @@ -272,10 +239,14 @@ protected static function fetchNavigationItems() $configShared = Config::navigation($type); $configShared->getConfigObject()->setKeyColumn('name'); foreach ($config->select() as $itemConfig) { - $menuItems[] = ["name" => $itemConfig->get('name'), "url" => $itemConfig->get('url')]; + if ( $itemConfig->get("target", "") !== "_blank") { + $menuItems[] = ["name" => $itemConfig->get('name'), "url" => $itemConfig->get('url')]; + } } foreach ($configShared->select() as $itemConfig) { - $menuItems[] = ["name" => $itemConfig->get('name'), "url" => $itemConfig->get('url')]; + if (Icinga::app()->hasAccessToSharedNavigationItem($itemConfig, $config) && $itemConfig->get("target", "") !== "_blank") { + $menuItems[] = ["name" => $itemConfig->get('name'), "url" => $itemConfig->get('url')]; + } } } return $menuItems; From 018a920a54909858934c8599ec2ce98fd79613f7 Mon Sep 17 00:00:00 2001 From: Davide Zeni Date: Tue, 2 Sep 2025 11:36:40 +0200 Subject: [PATCH 05/96] Refactor CSP handling to improve user checks --- library/Icinga/Util/Csp.php | 76 +++++++++++++++++++++---------------- 1 file changed, 43 insertions(+), 33 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index e307477743..8105071b07 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -56,17 +56,13 @@ private function __construct() */ public static function addHeader(Response $response): void { - $user = Auth::getInstance()->getUser(); $header = static::getContentSecurityPolicy(); - Logger::debug("Setting Content-Security-Policy header for user {$user->getUsername()} to $header"); $response->setHeader('Content-Security-Policy', $header, true); } /** * Get the Content-Security-Policy for a specific user. * - * @param User $user - * * @throws RuntimeException If no nonce set for CSS * * @return string Returns the generated header value. @@ -231,18 +227,22 @@ protected static function fetchDashletNavigationItemConfigs() */ protected static function fetchNavigationItems() { - $username = Auth::getInstance()->getUser()->getUsername(); + $user = Auth::getInstance()->getUser(); + $menuItems = []; + if ($user === null) { + return $menuItems; + } $navigationType = Navigation::getItemTypeConfiguration(); foreach ($navigationType as $type => $_) { - $config = Config::navigation($type, $username); + $config = Config::navigation($type, $user->getUsername()); $config->getConfigObject()->setKeyColumn('name'); - $configShared = Config::navigation($type); - $configShared->getConfigObject()->setKeyColumn('name'); foreach ($config->select() as $itemConfig) { - if ( $itemConfig->get("target", "") !== "_blank") { + if ($itemConfig->get("target", "") !== "_blank") { $menuItems[] = ["name" => $itemConfig->get('name'), "url" => $itemConfig->get('url')]; } } + $configShared = Config::navigation($type); + $configShared->getConfigObject()->setKeyColumn('name'); foreach ($configShared->select() as $itemConfig) { if (Icinga::app()->hasAccessToSharedNavigationItem($itemConfig, $config) && $itemConfig->get("target", "") !== "_blank") { $menuItems[] = ["name" => $itemConfig->get('name'), "url" => $itemConfig->get('url')]; @@ -260,34 +260,44 @@ protected static function fetchNavigationItems() */ protected static function fetchDashletsItems() { - $dashboard = new Dashboard(); - $dashboard->setUser(Auth::getInstance()->getUser()); - $dashboard->load(); - $dashlets = []; - foreach ($dashboard->getPanes() as $pane) { - foreach ($pane->getDashlets() as $dashlet) { - $url = $dashlet->getUrl(); - // Prefer explicit external URL parameter if present - $externalUrl = $url->getParam("url"); - if ($externalUrl !== null) { - $dashlets[] = [ - "name" => $dashlet->getName(), - "url" => $externalUrl - ]; - continue; - } + $user = Auth::getInstance()->getUser(); + $dashlets = []; + if ($user === null) { + return $dashlets; + } + + $dashboard = new Dashboard(); + $dashboard->setUser($user); + $dashboard->load(); + + foreach ($dashboard->getPanes() as $pane) { + foreach ($pane->getDashlets() as $dashlet) { + $url = $dashlet->getUrl(); + if ($url === null) { + continue; + } + + $externalUrl = $url->getParam("url"); + if ($externalUrl !== null && filter_var($externalUrl, FILTER_VALIDATE_URL) !== false) { + $dashlets[] = [ + "name" => $dashlet->getName(), + "url" => $externalUrl + ]; + continue; + } - // Otherwise, check if the dashlet URL itself is external - if ($url->isExternal()) { + if ($url->isExternal()) { + $absoluteUrl = $url->getAbsoluteUrl(); + if (filter_var($absoluteUrl, FILTER_VALIDATE_URL) !== false) { $dashlets[] = [ - "name" => $dashlet->getName(), - "url" => $url->getAbsoluteUrl() + "name" => $dashlet->getName(), + "url" => $absoluteUrl ]; } - } - } - - return $dashlets; + } + } + } + return $dashlets; } } From 4b31e94e5bef1f9d90462f8f0d66e1a891efa497 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 11 Mar 2026 08:28:08 +0100 Subject: [PATCH 06/96] Add a table which displays where a CSP directive comes from --- application/controllers/ConfigController.php | 34 +++++ .../views/scripts/config/general.phtml | 2 + library/Icinga/Util/Csp.php | 141 +++++++++++++----- 3 files changed, 142 insertions(+), 35 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 7a1246fa84..a64dcaf372 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -7,6 +7,7 @@ use Exception; use Icinga\Application\Version; +use Icinga\Util\Csp; use InvalidArgumentException; use Icinga\Application\Config; use Icinga\Application\Icinga; @@ -25,6 +26,7 @@ use Icinga\Web\Notification; use Icinga\Web\Url; use Icinga\Web\Widget; +use ipl\Html\Table; /** * Application and module configuration @@ -113,6 +115,38 @@ public function generalAction() $this->view->form = $form; $this->view->title = $this->translate('General'); + + $this->view->cspTable = ""; + if ($form->getSubForm('form_config_general_application')->getValue('security_use_strict_csp')) { + $table = new Table(); + $table->add(Table::tr([ + Table::th(t('Type')), + Table::th(t('Info')), + Table::th(t('Directive')), + Table::th(t('Value')), + ])); + $policyDirectives = Csp::collectContentSecurityPolicyDirectives(); + foreach ($policyDirectives as $directiveGroup) { + $reason = $directiveGroup['reason']; + $type = $reason['type']; + $info = match ($type) { + 'dashlet' => $reason['pane'] . '/' . $reason['dashlet'], + 'hook' => $reason['hook'], + default => '-', + }; + foreach ($directiveGroup['directives'] as $directive => $policies) { + $table->add(Table::tr([ + Table::td($type), + Table::td($info), + Table::td($directive), + Table::td(join(', ', $policies)), + ])); + } + } + + $this->view->cspTable = $table->render(); + } + $this->createApplicationTabs()->activate('general'); } diff --git a/application/views/scripts/config/general.phtml b/application/views/scripts/config/general.phtml index 13a8ed9ed1..4731c41367 100644 --- a/application/views/scripts/config/general.phtml +++ b/application/views/scripts/config/general.phtml @@ -3,4 +3,6 @@
+ +
diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 8105071b07..054fed5dfe 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -60,29 +60,9 @@ public static function addHeader(Response $response): void $response->setHeader('Content-Security-Policy', $header, true); } - /** - * Get the Content-Security-Policy for a specific user. - * - * @throws RuntimeException If no nonce set for CSS - * - * @return string Returns the generated header value. - */ - public static function getContentSecurityPolicy(): string + public static function collectContentSecurityPolicyDirectives(): array { - $csp = static::getInstance(); - - if (empty($csp->styleNonce)) { - throw new RuntimeException('No nonce set for CSS'); - } - - // These are the default directives that should always be enforced. 'self' is valid for all - // directives and will therefor not be listed here. - $cspDirectives = [ - 'style-src' => ["'nonce-{$csp->styleNonce}'"], - 'font-src' => ["data:"], - 'img-src' => ["data:"], - 'frame-src' => [] - ]; + $policyDirectives = []; // Whitelist the hosts in the custom NavigationItems configured for the user, // so that the iframes can be rendered properly. @@ -100,7 +80,6 @@ public static function getContentSecurityPolicy(): string $scheme = parse_url($navigationItem["url"], PHP_URL_SCHEME); - if ($host === null) { continue; } @@ -110,11 +89,19 @@ public static function getContentSecurityPolicy(): string $policy = "$scheme://$host"; } - $cspDirectives['frame-src'][] = $policy; + $policyDirectives[] = [ + 'directives' => [ + 'frame-src' => [$policy], + ], + 'reason' => $navigationItem['reason'], + ]; +// +// $cspDirectives['frame-src'][] = $policy; } // Allow modules to add their own csp directives in a limited fashion. /** @var CspDirectiveHook $hook */ foreach (Hook::all('CspDirective') as $hook) { + $directives = []; foreach ($hook->getCspDirectives() as $directive => $policies) { // policy names contain only lowercase letters and '-'. Reject anything else. if (!preg_match('|^[a-z\-]+$|', $directive)) { @@ -130,17 +117,70 @@ public static function getContentSecurityPolicy(): string continue; } - $cspDirectives[$directive] = $cspDirectives[$directive] ?? []; - foreach ($policies as $policy) { - $cspDirectives[$directive][] = $policy; +// $cspDirectives[$directive] = $cspDirectives[$directive] ?? []; +// foreach ($policies as $policy) { +// $cspDirectives[$directive][] = $policy; +// } + + if (count($policies) === 0) { + continue; } + + $directives[$directive] = $policies; + } + + if (count($directives) === 0) { + continue; + } + + $policyDirectives[] = [ + "directives" => $directives, + "reason" => [ + "type" => "hook", + "hook" => get_class($hook), + ], + ]; + } + + return $policyDirectives; + } + + /** + * Get the Content-Security-Policy for a specific user. + * + * @throws RuntimeException If no nonce set for CSS + * + * @return string Returns the generated header value. + */ + public static function getContentSecurityPolicy(): string + { + $csp = static::getInstance(); + + if (empty($csp->styleNonce)) { + throw new RuntimeException('No nonce set for CSS'); + } + + // These are the default directives that should always be enforced. 'self' is valid for all + // directives and will therefor not be listed here. + $cspDirectives = [ + 'style-src' => ["'nonce-{$csp->styleNonce}'"], + 'font-src' => ["data:"], + 'img-src' => ["data:"], + 'frame-src' => [] + ]; + + $policyDirectives = self::collectContentSecurityPolicyDirectives(); + + foreach ($policyDirectives as $directive) { + foreach ($directive['directives'] as $directive => $policies) { + $cspDirectives[$directive] = array_merge($cspDirectives[$directive], $policies); } } $header = "default-src 'self'; "; - foreach ($cspDirectives as $directive => $policies) { - if (!empty($policies)) { - $header .= ' ' . implode(' ', array_merge([$directive, "'self'"], array_unique($policies))) . ';'; + foreach ($cspDirectives as $directive => $policyDirectives) { + if (!empty($policyDirectives)) { + $header .= ' ' . implode(' ', array_merge([$directive, "'self'"], array_unique($policyDirectives))) . ';'; } } @@ -238,14 +278,33 @@ protected static function fetchNavigationItems() $config->getConfigObject()->setKeyColumn('name'); foreach ($config->select() as $itemConfig) { if ($itemConfig->get("target", "") !== "_blank") { - $menuItems[] = ["name" => $itemConfig->get('name'), "url" => $itemConfig->get('url')]; + $menuItems[] = [ + "name" => $itemConfig->get('name'), + "url" => $itemConfig->get('url'), + "reason" => [ + 'type' => 'navigation', + 'name' => $itemConfig->get('name'), + 'shared' => false, + ] + ]; } } $configShared = Config::navigation($type); $configShared->getConfigObject()->setKeyColumn('name'); foreach ($configShared->select() as $itemConfig) { - if (Icinga::app()->hasAccessToSharedNavigationItem($itemConfig, $config) && $itemConfig->get("target", "") !== "_blank") { - $menuItems[] = ["name" => $itemConfig->get('name'), "url" => $itemConfig->get('url')]; + if ( + Icinga::app()->hasAccessToSharedNavigationItem($itemConfig, $config) && + $itemConfig->get("target", "") !== "_blank" + ) { + $menuItems[] = [ + "name" => $itemConfig->get('name'), + "url" => $itemConfig->get('url'), + "reason" => [ + 'type' => 'navigation', + 'name' => $itemConfig->get('name'), + 'shared' => true, + ] + ]; } } } @@ -281,7 +340,13 @@ protected static function fetchDashletsItems() if ($externalUrl !== null && filter_var($externalUrl, FILTER_VALIDATE_URL) !== false) { $dashlets[] = [ "name" => $dashlet->getName(), - "url" => $externalUrl + "url" => $externalUrl, + "reason" => [ + "type" => "dashlet", + "user" => $user->getUsername(), + "pane" => $pane->getName(), + "dashlet" => $dashlet->getName(), + ], ]; continue; } @@ -291,7 +356,13 @@ protected static function fetchDashletsItems() if (filter_var($absoluteUrl, FILTER_VALIDATE_URL) !== false) { $dashlets[] = [ "name" => $dashlet->getName(), - "url" => $absoluteUrl + "url" => $absoluteUrl, + "reason" => [ + "type" => "dashlet-iframe", + "user" => $user->getUsername(), + "pane" => $pane->getName(), + "dashlet" => $dashlet->getName(), + ], ]; } } From c0d456b4a1119d7eb8f4c314f794f5041110a883 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 11 Mar 2026 11:10:44 +0100 Subject: [PATCH 07/96] Move CSP table into its own Widget This commit also adds headers to the general config section --- application/controllers/ConfigController.php | 60 +++++------------ .../Config/General/ApplicationConfigForm.php | 19 ------ .../forms/Config/General/CspConfigForm.php | 64 +++++++++++++++++++ .../views/scripts/config/general.phtml | 3 + .../Web/Widget/CspConfigurationTable.php | 47 ++++++++++++++ public/css/icinga/widgets.less | 5 ++ 6 files changed, 135 insertions(+), 63 deletions(-) create mode 100644 application/forms/Config/General/CspConfigForm.php create mode 100644 library/Icinga/Web/Widget/CspConfigurationTable.php diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index a64dcaf372..235c21925d 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -6,8 +6,10 @@ namespace Icinga\Controllers; use Exception; +use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; -use Icinga\Util\Csp; +use Icinga\Forms\Config\General\CspConfigForm; +use Icinga\Web\Widget\CspConfigurationTable; use InvalidArgumentException; use Icinga\Application\Config; use Icinga\Application\Icinga; @@ -26,7 +28,6 @@ use Icinga\Web\Notification; use Icinga\Web\Url; use Icinga\Web\Widget; -use ipl\Html\Table; /** * Application and module configuration @@ -98,54 +99,25 @@ public function indexAction() public function generalAction() { $this->assertPermission('config/general'); + + $this->view->title = $this->translate('General'); + $form = new GeneralConfigForm(); $form->setIniConfig(Config::app()); - $form->setOnSuccess(function (GeneralConfigForm $form) { - $config = Config::app(); - $useStrictCsp = (bool) $config->get('security', 'use_strict_csp', false); - if ($form->onSuccess() === false) { - return false; - } - - $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->view->cspTable = ""; - if ($form->getSubForm('form_config_general_application')->getValue('security_use_strict_csp')) { - $table = new Table(); - $table->add(Table::tr([ - Table::th(t('Type')), - Table::th(t('Info')), - Table::th(t('Directive')), - Table::th(t('Value')), - ])); - $policyDirectives = Csp::collectContentSecurityPolicyDirectives(); - foreach ($policyDirectives as $directiveGroup) { - $reason = $directiveGroup['reason']; - $type = $reason['type']; - $info = match ($type) { - 'dashlet' => $reason['pane'] . '/' . $reason['dashlet'], - 'hook' => $reason['hook'], - default => '-', - }; - foreach ($directiveGroup['directives'] as $directive => $policies) { - $table->add(Table::tr([ - Table::td($type), - Table::td($info), - Table::td($directive), - Table::td(join(', ', $policies)), - ])); - } - } + $cspForm = new CspConfigForm(); + $config = Config::app(); + $cspForm->populate([ + 'use_strict_csp' => $config->get('security', 'use_strict_csp'), + 'custom_csp' => $config->get('security', 'custom_csp'), + ]); + $cspForm->handleRequest(ServerRequest::fromGlobals()); + $this->view->cspForm = $cspForm; - $this->view->cspTable = $table->render(); - } + $this->view->cspTable = (new CspConfigurationTable())->render(); $this->createApplicationTabs()->activate('general'); } diff --git a/application/forms/Config/General/ApplicationConfigForm.php b/application/forms/Config/General/ApplicationConfigForm.php index 3e2391697f..47a21c8da3 100644 --- a/application/forms/Config/General/ApplicationConfigForm.php +++ b/application/forms/Config/General/ApplicationConfigForm.php @@ -59,25 +59,6 @@ public function createElements(array $formData) ) ); - $this->addElement( - 'checkbox', - 'security_use_strict_csp', - [ - 'label' => $this->translate('Enable strict content security policy'), - 'autosubmit' => true, - 'description' => $this->translate( - 'Set whether to use strict content security policy (CSP).' - . ' This setting helps to protect from cross-site scripting (XSS).' - ) - ] - ); - - if ($formData['security_use_strict_csp']) { - Csp::createNonce(); - $header = Csp::getContentSecurityPolicy(Auth::getInstance()->getUser()); - $this->addHint("Content-Security-Policy: $header"); - } - $this->addElement( 'text', 'global_module_path', diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php new file mode 100644 index 0000000000..2cd285f6a0 --- /dev/null +++ b/application/forms/Config/General/CspConfigForm.php @@ -0,0 +1,64 @@ +setAttribute("name", "csp_config"); + $this->applyDefaultElementDecorators(); + } + + protected function assemble(): void + { + $this->addElement($this->createUidElement()); + + $this->addCsrfCounterMeasure(Session::getSession()->getId()); + + $this->addElement( + 'checkbox', + 'use_strict_csp', + [ + 'label' => $this->translate('Enable strict CSP'), + 'description' => $this->translate( + 'Set whether to use strict content security policy (CSP).' + . ' This setting helps to protect from cross-site scripting (XSS).' + ), + ], + ); + + $this->addElement('textarea', 'custom_csp', [ + 'label' => 'Custom CSP', + 'description' => $this->translate( + 'Set custom CSP directives. These values are parsed and merged with the values supplied by modules' + . ' and navigation items.' + ), + ]); + + $this->addElement('submit', 'submit', [ + 'label' => t('Save changes'), + ]); + } + + protected function onSuccess(): void + { + $config = Config::app(); + + $section = $config->getSection('security'); + $section['use_strict_csp'] = $this->getValue('use_strict_csp'); + $section['custom_csp'] = $this->getValue('custom_csp'); + $config->setSection('security', $section); + + $config->saveIni(); + } +} diff --git a/application/views/scripts/config/general.phtml b/application/views/scripts/config/general.phtml index 4731c41367..ecb387c8d5 100644 --- a/application/views/scripts/config/general.phtml +++ b/application/views/scripts/config/general.phtml @@ -2,7 +2,10 @@
+

translate('General') ?>

+

translate('Content Security Policy') ?>

+
diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php new file mode 100644 index 0000000000..fb0e243de6 --- /dev/null +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -0,0 +1,47 @@ +getAttributes()->add('class', 'csp-config-table'); + } + + protected function assemble(): void + { + $this->add(self::tr([ + self::th($this->translate('Type')), + self::th($this->translate('Info')), + self::th($this->translate('Directive')), + self::th($this->translate('Value')), + ])); + + $policyDirectives = Csp::collectContentSecurityPolicyDirectives(); + + foreach ($policyDirectives as $directiveGroup) { + $reason = $directiveGroup['reason']; + $type = $reason['type']; + $info = match ($type) { + 'dashlet' => $reason['pane'] . '/' . $reason['dashlet'], + 'hook' => $reason['hook'], + default => '-', + }; + foreach ($directiveGroup['directives'] as $directive => $policies) { + $this->add(self::tr([ + self::td($type), + self::td($info), + self::td($directive), + self::td(join(', ', $policies)), + ])); + } + } + } +} diff --git a/public/css/icinga/widgets.less b/public/css/icinga/widgets.less index c482f0ce5e..fa3dcf0c68 100644 --- a/public/css/icinga/widgets.less +++ b/public/css/icinga/widgets.less @@ -665,3 +665,8 @@ ul.tree li a.error:hover { html.no-js .progress-label { display: none; } + +.csp-config-table { + width: 80%; + max-width: 70em; +} From d134e322bd332029ca282226046415d4a1804ede Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 11 Mar 2026 11:11:20 +0100 Subject: [PATCH 08/96] Integrate the custom CSP setting --- library/Icinga/Util/Csp.php | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 054fed5dfe..7632cd66c5 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -142,6 +142,8 @@ public static function collectContentSecurityPolicyDirectives(): array ]; } + $policyDirectives = array_merge($policyDirectives, self::fetchCustomCspDirectives()); + return $policyDirectives; } @@ -173,6 +175,9 @@ public static function getContentSecurityPolicy(): string foreach ($policyDirectives as $directive) { foreach ($directive['directives'] as $directive => $policies) { + if (! isset($cspDirectives[$directive])) { + $cspDirectives[$directive] = []; + } $cspDirectives[$directive] = array_merge($cspDirectives[$directive], $policies); } } @@ -240,7 +245,37 @@ protected static function getInstance(): self return static::$instance; } - + + public static function fetchCustomCspDirectives(): array + { + $config = Config::app(); + $setting = $config->get('security', 'custom_csp'); + + if ($setting === null) { + return []; + } + + $menuDirectives = []; + + $sections = explode(';', $setting); + foreach ($sections as $section) { + $parts = explode(' ', trim($section)); + if (count ($parts) < 2) { + continue; + } + $directive = array_shift($parts); + $menuDirectives[] = [ + 'directives' => [ + $directive => $parts, + ], + 'reason' => [ + 'type' => 'custom', + ], + ]; + } + + return $menuDirectives; + } /** * Fetches and merges configurations for navigation menu items and dashlets. From f1d922278d8aec979c4d43fc7f67ae434d712860 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 09:34:43 +0100 Subject: [PATCH 09/96] Use new hook style --- .../Application/Hook/CspDirectiveHook.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspDirectiveHook.php index 43eb6c26eb..451248be73 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspDirectiveHook.php @@ -3,6 +3,8 @@ namespace Icinga\Application\Hook; +use Icinga\Application\Hook; + abstract class CspDirectiveHook { /** @@ -15,4 +17,24 @@ abstract class CspDirectiveHook * @return array The CSP directives are the keys and the policies the values. */ abstract public function getCspDirectives(): array; + + /** + * Get all registered implementations + * + * @return static[] + */ + public static function all(): array + { + return Hook::all('CspDirective'); + } + + /** + * Register the class as a RequestHook 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('CspDirective', static::class, static::class, true); + } } From 7477372eef71d05ae959c44d1554c8553aaaffc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 09:37:50 +0100 Subject: [PATCH 10/96] Custom CSP should completely override the automatically generated one --- library/Icinga/Util/Csp.php | 125 ++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 71 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 7632cd66c5..556aaa91a9 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -19,6 +19,7 @@ use Icinga\Web\Navigation\Navigation; use Icinga\Web\Widget\Dashboard; +use Throwable; use function ipl\Stdlib\get_php_type; /** @@ -95,66 +96,78 @@ public static function collectContentSecurityPolicyDirectives(): array ], 'reason' => $navigationItem['reason'], ]; -// -// $cspDirectives['frame-src'][] = $policy; } + // Allow modules to add their own csp directives in a limited fashion. - /** @var CspDirectiveHook $hook */ - foreach (Hook::all('CspDirective') as $hook) { + foreach (CspDirectiveHook::all() as $hook) { $directives = []; - foreach ($hook->getCspDirectives() as $directive => $policies) { - // policy names contain only lowercase letters and '-'. Reject anything else. - if (!preg_match('|^[a-z\-]+$|', $directive)) { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Invalid CSP directive found: $directive"); - continue; - } - - // The default-src can only ever be 'self'. Disallow any updates to it. - if ($directive === "default-src") { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Changing default-src is forbidden."); - continue; + try { + foreach ($hook->getCspDirectives() as $directive => $policies) { + // policy names contain only lowercase letters and '-'. Reject anything else. + if (! preg_match('|^[a-z\-]+$|', $directive)) { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Invalid CSP directive found: $directive"); + continue; + } + + // The default-src can only ever be 'self'. Disallow any updates to it. + if ($directive === "default-src") { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Changing default-src is forbidden."); + continue; + } + + if (count($policies) === 0) { + continue; + } + + $directives[$directive] = $policies; } -// $cspDirectives[$directive] = $cspDirectives[$directive] ?? []; -// foreach ($policies as $policy) { -// $cspDirectives[$directive][] = $policy; -// } - - if (count($policies) === 0) { + if (count($directives) === 0) { continue; } - $directives[$directive] = $policies; - } - - if (count($directives) === 0) { - continue; + $policyDirectives[] = [ + "directives" => $directives, + "reason" => [ + "type" => "hook", + "hook" => get_class($hook), + ], + ]; + } catch (Throwable $e) { + Logger::error('Failed to CSP hook on request: %s', $e); } - - $policyDirectives[] = [ - "directives" => $directives, - "reason" => [ - "type" => "hook", - "hook" => get_class($hook), - ], - ]; } - $policyDirectives = array_merge($policyDirectives, self::fetchCustomCspDirectives()); - return $policyDirectives; } /** - * Get the Content-Security-Policy for a specific user. + * Get the Content-Security-Policy. * * @throws RuntimeException If no nonce set for CSS * * @return string Returns the generated header value. */ public static function getContentSecurityPolicy(): string + { + $config = Config::app(); + if ($config->get('security', 'use_custom_csp', 'y') === 'y') { + return $config->get('security', 'custom_csp', ''); + } + + return self::getAutomaticContentSecurityPolicy(); + } + + /** + * Get the automatically generated Content-Security-Policy. + * + * @throws RuntimeException If no nonce set for CSS + * + * @return string Returns the generated header value. + */ + public static function getAutomaticContentSecurityPolicy(): string { $csp = static::getInstance(); @@ -163,7 +176,7 @@ public static function getContentSecurityPolicy(): string } // These are the default directives that should always be enforced. 'self' is valid for all - // directives and will therefor not be listed here. + // directives and will therefore not be listed here. $cspDirectives = [ 'style-src' => ["'nonce-{$csp->styleNonce}'"], 'font-src' => ["data:"], @@ -191,6 +204,7 @@ public static function getContentSecurityPolicy(): string return $header; } + /** * Set/recreate nonce for dynamic CSS * @@ -246,37 +260,6 @@ protected static function getInstance(): self return static::$instance; } - public static function fetchCustomCspDirectives(): array - { - $config = Config::app(); - $setting = $config->get('security', 'custom_csp'); - - if ($setting === null) { - return []; - } - - $menuDirectives = []; - - $sections = explode(';', $setting); - foreach ($sections as $section) { - $parts = explode(' ', trim($section)); - if (count ($parts) < 2) { - continue; - } - $directive = array_shift($parts); - $menuDirectives[] = [ - 'directives' => [ - $directive => $parts, - ], - 'reason' => [ - 'type' => 'custom', - ], - ]; - } - - return $menuDirectives; - } - /** * Fetches and merges configurations for navigation menu items and dashlets. * From af40aca70379633fd2ea69761be3c44ada16dcda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 09:42:51 +0100 Subject: [PATCH 11/96] Allow configuration of the custom CSP-Header --- application/controllers/ConfigController.php | 4 +- .../forms/Config/General/CspConfigForm.php | 38 +++++++++++++++++-- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 235c21925d..145ccdd82e 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -108,11 +108,11 @@ public function generalAction() $this->view->form = $form; - $cspForm = new CspConfigForm(); $config = Config::app(); + $cspForm = new CspConfigForm($config); $cspForm->populate([ 'use_strict_csp' => $config->get('security', 'use_strict_csp'), - 'custom_csp' => $config->get('security', 'custom_csp'), + 'use_custom_csp' => $config->get('security', 'use_custom_csp'), ]); $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 2cd285f6a0..fe61373b32 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -3,6 +3,7 @@ namespace Icinga\Forms\Config\General; use Icinga\Application\Config; +use Icinga\Util\Csp; use Icinga\Web\Session; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Common\FormUid; @@ -13,7 +14,7 @@ class CspConfigForm extends CompatForm use FormUid; use CsrfCounterMeasure; - public function __construct() + public function __construct(protected Config $config) { $this->setAttribute("name", "csp_config"); $this->applyDefaultElementDecorators(); @@ -37,14 +38,44 @@ protected function assemble(): void ], ); + $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.' + . ' If you decide to provide your own CSP-Header, you are entirely responsible for keeping it' + . ' up-to-date.' + ), + 'class' => 'autosubmit', + ] + ); + + $this->addElement('hidden', 'hidden_custom_csp'); + + $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; $this->addElement('textarea', 'custom_csp', [ 'label' => 'Custom CSP', 'description' => $this->translate( - 'Set custom CSP directives. These values are parsed and merged with the values supplied by modules' - . ' and navigation items.' + 'Set a custom CSP-Header. This completely overrides the automatically generated one.' ), + 'disabled' => ! $useCustomCsp, ]); + $customCspElement = $this->getElement('custom_csp'); + if ($useCustomCsp) { + $value = $this->getPopulatedValue('hidden_custom_csp'); + if (! empty($value)) { + $customCspElement->setValue($value); + } else { + $customCspElement->setValue($this->config->get('security', 'custom_csp')); + } + } else { + $this->getElement('hidden_custom_csp')->setValue($this->getValue('custom_csp')); + $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); + } + $this->addElement('submit', 'submit', [ 'label' => t('Save changes'), ]); @@ -56,6 +87,7 @@ protected function onSuccess(): void $section = $config->getSection('security'); $section['use_strict_csp'] = $this->getValue('use_strict_csp'); + $section['use_custom_csp'] = $this->getValue('use_custom_csp'); $section['custom_csp'] = $this->getValue('custom_csp'); $config->setSection('security', $section); From 18156c3f79364b2d70c9392be87c85e6bf57c3cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 11:55:37 +0100 Subject: [PATCH 12/96] Move the check to send the CSP header into the Csp::isCspEnabled method --- library/Icinga/Util/Csp.php | 21 ++++++++++++--------- library/Icinga/Web/Response.php | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 556aaa91a9..44716c4bd9 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -5,13 +5,11 @@ namespace Icinga\Util; -use Icinga\Application\Hook; use Icinga\Application\Hook\CspDirectiveHook; use Icinga\Application\Icinga; use Icinga\Application\Logger; use Icinga\Authentication\Auth; use Icinga\Data\ConfigObject; -use Icinga\User; use Icinga\Web\Response; use Icinga\Web\Window; use Icinga\Application\Config; @@ -35,11 +33,11 @@ */ class Csp { - /** @var static */ - protected static $instance; + /** @var self|null */ + protected static ?self $instance = null; /** @var ?string */ - protected $styleNonce; + protected ?string $styleNonce = null; /** Singleton */ private function __construct() @@ -47,7 +45,7 @@ private function __construct() } /** - * Add Content-Security-Policy header with a nonce for dynamic CSS + * Add a Content-Security-Policy header with a nonce for dynamic CSS * * Note that {@see static::createNonce()} must be called beforehand. * @@ -61,6 +59,11 @@ public static function addHeader(Response $response): void $response->setHeader('Content-Security-Policy', $header, true); } + public static function isCspEnabled(): bool + { + return Config::app()->get('security', 'use_strict_csp', 'n') === 'y'; + } + public static function collectContentSecurityPolicyDirectives(): array { $policyDirectives = []; @@ -266,7 +269,7 @@ protected static function getInstance(): self * @return array An array containing both navigation items and dashlet configurations. * // returns [['name' => 'Item Name', 'url' => 'https://example.com'], ...] */ - protected static function fetchDashletNavigationItemConfigs() + protected static function fetchDashletNavigationItemConfigs(): array { return array_merge( self::fetchNavigationItems(), @@ -283,7 +286,7 @@ protected static function fetchDashletNavigationItemConfigs() * @return array Each item is an associative array with 'name' and 'url' keys. * Example: [ ['name' => 'Home', 'url' => '/'], ['name' => 'Profile', 'url' => '/profile'] ] */ - protected static function fetchNavigationItems() + protected static function fetchNavigationItems(): array { $user = Auth::getInstance()->getUser(); $menuItems = []; @@ -335,7 +338,7 @@ protected static function fetchNavigationItems() * @return array A list of dashlets with their names and absolute URLs. * // returns [['name' => 'Dashlet Name', 'url' => 'https://external.dashlet.com'], ...] */ - protected static function fetchDashletsItems() + protected static function fetchDashletsItems(): array { $user = Auth::getInstance()->getUser(); $dashlets = []; diff --git a/library/Icinga/Web/Response.php b/library/Icinga/Web/Response.php index 19c25ddbb6..7a9e6033b7 100644 --- a/library/Icinga/Web/Response.php +++ b/library/Icinga/Web/Response.php @@ -383,7 +383,7 @@ protected function prepare() $this->setRedirect($redirectUrl->getAbsoluteUrl()); } - if (Csp::getStyleNonce() && Config::app()->get('security', 'use_strict_csp', false)) { + if (Csp::getStyleNonce() && Csp::isCspEnabled()) { Csp::addHeader($this); } } From d7128e608b713abb871ca384b90e1356a36130e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 11:56:23 +0100 Subject: [PATCH 13/96] Fix a bug that caused the custom CSP textarea to be empty --- .../forms/Config/General/CspConfigForm.php | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index fe61373b32..d81897555e 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -56,14 +56,25 @@ protected function assemble(): void $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; $this->addElement('textarea', 'custom_csp', [ - 'label' => 'Custom CSP', + 'label' => $useCustomCsp ? $this->translate('Custom CSP') : $this->translate('Generated CSP'), 'description' => $this->translate( 'Set a custom CSP-Header. This completely overrides the automatically generated one.' ), 'disabled' => ! $useCustomCsp, ]); + $this->addElement('submit', 'submit', [ + 'label' => t('Save changes'), + ]); + $customCspElement = $this->getElement('custom_csp'); + if ($this->hasBeenSubmitted()) { + if (! $useCustomCsp) { + $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); + } + return; + } + if ($useCustomCsp) { $value = $this->getPopulatedValue('hidden_custom_csp'); if (! empty($value)) { @@ -75,10 +86,6 @@ protected function assemble(): void $this->getElement('hidden_custom_csp')->setValue($this->getValue('custom_csp')); $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); } - - $this->addElement('submit', 'submit', [ - 'label' => t('Save changes'), - ]); } protected function onSuccess(): void @@ -88,7 +95,10 @@ protected function onSuccess(): void $section = $config->getSection('security'); $section['use_strict_csp'] = $this->getValue('use_strict_csp'); $section['use_custom_csp'] = $this->getValue('use_custom_csp'); - $section['custom_csp'] = $this->getValue('custom_csp'); + $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; + if ($useCustomCsp) { + $section['custom_csp'] = $this->getValue('custom_csp'); + } $config->setSection('security', $section); $config->saveIni(); From 9c83530ba18c45b049e406c7a81d56726eb4d508 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 13:18:24 +0100 Subject: [PATCH 14/96] Allow for the usage of {style_nonce} in the custom CSP-Header setting --- library/Icinga/Util/Csp.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 44716c4bd9..a82c9cb679 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -157,12 +157,26 @@ public static function getContentSecurityPolicy(): string { $config = Config::app(); if ($config->get('security', 'use_custom_csp', 'y') === 'y') { - return $config->get('security', 'custom_csp', ''); + return self::getCustomContentSecurityPolicy(); } return self::getAutomaticContentSecurityPolicy(); } + public static function getCustomContentSecurityPolicy(): ?string + { + $csp = static::getInstance(); + + if (empty($csp->styleNonce)) { + throw new RuntimeException('No nonce set for CSS'); + } + + $config = Config::app(); + $raw = $config->get('security', 'custom_csp'); + $formated = str_replace('{style_nonce}', "'nonce{$csp->styleNonce}'", $raw); + return $formated; + } + /** * Get the automatically generated Content-Security-Policy. * From 2061b200d320be5a907fbbd8d8f6e128ba15f1f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 13:32:30 +0100 Subject: [PATCH 15/96] Allow newlines in custom CSP --- library/Icinga/Util/Csp.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index a82c9cb679..00e5b13d36 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -163,7 +163,7 @@ public static function getContentSecurityPolicy(): string return self::getAutomaticContentSecurityPolicy(); } - public static function getCustomContentSecurityPolicy(): ?string + protected static function getCustomContentSecurityPolicy(): ?string { $csp = static::getInstance(); @@ -172,9 +172,11 @@ public static function getCustomContentSecurityPolicy(): ?string } $config = Config::app(); - $raw = $config->get('security', 'custom_csp'); - $formated = str_replace('{style_nonce}', "'nonce{$csp->styleNonce}'", $raw); - return $formated; + $customCsp = $config->get('security', 'custom_csp', ''); + $customCsp = str_replace("\r\n", ' ', $customCsp); + $customCsp = str_replace("\n", ' ', $customCsp); + $customCsp = str_replace('{style_nonce}', "'nonce-{$csp->styleNonce}'", $customCsp); + return $customCsp; } /** From 54ad4d45c9110b4fcf44f0156805aff984ce290f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 13:45:07 +0100 Subject: [PATCH 16/96] Add dynamic descryption for the custom CSP textarea --- .../forms/Config/General/CspConfigForm.php | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index d81897555e..670bfc953f 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -55,13 +55,25 @@ protected function assemble(): void $this->addElement('hidden', 'hidden_custom_csp'); $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; - $this->addElement('textarea', 'custom_csp', [ - 'label' => $useCustomCsp ? $this->translate('Custom CSP') : $this->translate('Generated CSP'), - 'description' => $this->translate( - 'Set a custom CSP-Header. This completely overrides the automatically generated one.' - ), - 'disabled' => ! $useCustomCsp, - ]); + if ($useCustomCsp) { + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate('Custom CSP'), + '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.' + ), + 'disabled' => false, + ]); + } else { + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate('Generated CSP'), + 'description' => $this->translate( + 'This is the current CSP-Header. You can always safely go back to this by disabling the' + . ' Enable Custom CSP checkbox above.' + ), + 'disabled' => true, + ]); + } $this->addElement('submit', 'submit', [ 'label' => t('Save changes'), From 4fec37665adaaf8bbd401ce0b4f1e8d906bf7573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 14:08:29 +0100 Subject: [PATCH 17/96] Fix code formating --- library/Icinga/Util/Csp.php | 145 ++++++++++++++++++------------------ 1 file changed, 72 insertions(+), 73 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 00e5b13d36..dbe0c6dcc2 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -5,18 +5,17 @@ namespace Icinga\Util; +use Icinga\Application\Config; use Icinga\Application\Hook\CspDirectiveHook; use Icinga\Application\Icinga; use Icinga\Application\Logger; use Icinga\Authentication\Auth; use Icinga\Data\ConfigObject; +use Icinga\Web\Navigation\Navigation; use Icinga\Web\Response; +use Icinga\Web\Widget\Dashboard; use Icinga\Web\Window; -use Icinga\Application\Config; use RuntimeException; -use Icinga\Web\Navigation\Navigation; -use Icinga\Web\Widget\Dashboard; - use Throwable; use function ipl\Stdlib\get_php_type; @@ -97,7 +96,7 @@ public static function collectContentSecurityPolicyDirectives(): array 'directives' => [ 'frame-src' => [$policy], ], - 'reason' => $navigationItem['reason'], + 'reason' => $navigationItem['reason'], ]; } @@ -149,9 +148,9 @@ public static function collectContentSecurityPolicyDirectives(): array /** * Get the Content-Security-Policy. * + * @return string Returns the generated header value. * @throws RuntimeException If no nonce set for CSS * - * @return string Returns the generated header value. */ public static function getContentSecurityPolicy(): string { @@ -182,9 +181,9 @@ protected static function getCustomContentSecurityPolicy(): ?string /** * Get the automatically generated Content-Security-Policy. * + * @return string Returns the generated header value. * @throws RuntimeException If no nonce set for CSS * - * @return string Returns the generated header value. */ public static function getAutomaticContentSecurityPolicy(): string { @@ -198,9 +197,9 @@ public static function getAutomaticContentSecurityPolicy(): string // directives and will therefore not be listed here. $cspDirectives = [ 'style-src' => ["'nonce-{$csp->styleNonce}'"], - 'font-src' => ["data:"], - 'img-src' => ["data:"], - 'frame-src' => [] + 'font-src' => ["data:"], + 'img-src' => ["data:"], + 'frame-src' => [], ]; $policyDirectives = self::collectContentSecurityPolicyDirectives(); @@ -216,11 +215,13 @@ public static function getAutomaticContentSecurityPolicy(): string $header = "default-src 'self'; "; foreach ($cspDirectives as $directive => $policyDirectives) { - if (!empty($policyDirectives)) { - $header .= ' ' . implode(' ', array_merge([$directive, "'self'"], array_unique($policyDirectives))) . ';'; + if (! empty($policyDirectives)) { + $header .= ' ' . + implode(' ', array_merge([$directive, "'self'"], array_unique($policyDirectives))) + . ';'; } } - + return $header; } @@ -266,8 +267,8 @@ protected static function getInstance(): self throw new RuntimeException( sprintf( 'Nonce value is expected to be string, got %s instead', - get_php_type($nonce) - ) + get_php_type($nonce), + ), ); } @@ -289,7 +290,7 @@ protected static function fetchDashletNavigationItemConfigs(): array { return array_merge( self::fetchNavigationItems(), - self::fetchDashletsItems() + self::fetchDashletsItems(), ); } @@ -316,31 +317,30 @@ protected static function fetchNavigationItems(): array foreach ($config->select() as $itemConfig) { if ($itemConfig->get("target", "") !== "_blank") { $menuItems[] = [ - "name" => $itemConfig->get('name'), - "url" => $itemConfig->get('url'), + "name" => $itemConfig->get('name'), + "url" => $itemConfig->get('url'), "reason" => [ - 'type' => 'navigation', - 'name' => $itemConfig->get('name'), + 'type' => 'navigation', + 'name' => $itemConfig->get('name'), 'shared' => false, - ] + ], ]; } } $configShared = Config::navigation($type); $configShared->getConfigObject()->setKeyColumn('name'); foreach ($configShared->select() as $itemConfig) { - if ( - Icinga::app()->hasAccessToSharedNavigationItem($itemConfig, $config) && + if (Icinga::app()->hasAccessToSharedNavigationItem($itemConfig, $config) && $itemConfig->get("target", "") !== "_blank" ) { $menuItems[] = [ - "name" => $itemConfig->get('name'), - "url" => $itemConfig->get('url'), + "name" => $itemConfig->get('name'), + "url" => $itemConfig->get('url'), "reason" => [ - 'type' => 'navigation', - 'name' => $itemConfig->get('name'), + 'type' => 'navigation', + 'name' => $itemConfig->get('name'), 'shared' => true, - ] + ], ]; } } @@ -356,56 +356,55 @@ protected static function fetchNavigationItems(): array */ protected static function fetchDashletsItems(): array { - $user = Auth::getInstance()->getUser(); - $dashlets = []; - if ($user === null) { - return $dashlets; - } - - $dashboard = new Dashboard(); - $dashboard->setUser($user); - $dashboard->load(); - - foreach ($dashboard->getPanes() as $pane) { - foreach ($pane->getDashlets() as $dashlet) { - $url = $dashlet->getUrl(); - if ($url === null) { - continue; - } - - $externalUrl = $url->getParam("url"); - if ($externalUrl !== null && filter_var($externalUrl, FILTER_VALIDATE_URL) !== false) { - $dashlets[] = [ - "name" => $dashlet->getName(), - "url" => $externalUrl, - "reason" => [ - "type" => "dashlet", - "user" => $user->getUsername(), - "pane" => $pane->getName(), - "dashlet" => $dashlet->getName(), - ], - ]; - continue; - } + $user = Auth::getInstance()->getUser(); + $dashlets = []; + if ($user === null) { + return $dashlets; + } + + $dashboard = new Dashboard(); + $dashboard->setUser($user); + $dashboard->load(); + + foreach ($dashboard->getPanes() as $pane) { + foreach ($pane->getDashlets() as $dashlet) { + $url = $dashlet->getUrl(); + if ($url === null) { + continue; + } - if ($url->isExternal()) { - $absoluteUrl = $url->getAbsoluteUrl(); - if (filter_var($absoluteUrl, FILTER_VALIDATE_URL) !== false) { + $externalUrl = $url->getParam("url"); + if ($externalUrl !== null && filter_var($externalUrl, FILTER_VALIDATE_URL) !== false) { $dashlets[] = [ - "name" => $dashlet->getName(), - "url" => $absoluteUrl, - "reason" => [ - "type" => "dashlet-iframe", - "user" => $user->getUsername(), - "pane" => $pane->getName(), + "name" => $dashlet->getName(), + "url" => $externalUrl, + "reason" => [ + "type" => "dashlet", + "user" => $user->getUsername(), + "pane" => $pane->getName(), "dashlet" => $dashlet->getName(), ], ]; + continue; } - } - } - } - return $dashlets; - } + if ($url->isExternal()) { + $absoluteUrl = $url->getAbsoluteUrl(); + if (filter_var($absoluteUrl, FILTER_VALIDATE_URL) !== false) { + $dashlets[] = [ + "name" => $dashlet->getName(), + "url" => $absoluteUrl, + "reason" => [ + "type" => "dashlet-iframe", + "user" => $user->getUsername(), + "pane" => $pane->getName(), + "dashlet" => $dashlet->getName(), + ], + ]; + } + } + } + } + return $dashlets; + } } From 452bafd561410665dd946a087aa7cb41ae2add34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 16:09:35 +0100 Subject: [PATCH 18/96] Use generator to iterate the navigation items --- library/Icinga/Util/Csp.php | 70 ++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index dbe0c6dcc2..fe24e826dd 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -5,6 +5,7 @@ namespace Icinga\Util; +use Generator; use Icinga\Application\Config; use Icinga\Application\Hook\CspDirectiveHook; use Icinga\Application\Icinga; @@ -12,6 +13,7 @@ use Icinga\Authentication\Auth; use Icinga\Data\ConfigObject; use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Navigation\NavigationItem; use Icinga\Web\Response; use Icinga\Web\Widget\Dashboard; use Icinga\Web\Window; @@ -301,58 +303,60 @@ protected static function fetchDashletNavigationItemConfigs(): array * and shared configurations, and returns a list of menu items. * * @return array Each item is an associative array with 'name' and 'url' keys. - * Example: [ ['name' => 'Home', 'url' => '/'], ['name' => 'Profile', 'url' => '/profile'] ] + * Example: [ ['name' => 'Home', 'url' => '/', 'reason' => [...] ], ... ] */ protected static function fetchNavigationItems(): array { - $user = Auth::getInstance()->getUser(); - $menuItems = []; - if ($user === null) { - return $menuItems; + $auth = Auth::getInstance(); + if (! $auth->isAuthenticated()) { + return []; } + + $origins = []; $navigationType = Navigation::getItemTypeConfiguration(); foreach ($navigationType as $type => $_) { - $config = Config::navigation($type, $user->getUsername()); - $config->getConfigObject()->setKeyColumn('name'); - foreach ($config->select() as $itemConfig) { - if ($itemConfig->get("target", "") !== "_blank") { - $menuItems[] = [ - "name" => $itemConfig->get('name'), - "url" => $itemConfig->get('url'), - "reason" => [ + $navigation = new Navigation(); + foreach ($navigation->load($type) as $navItem) { + foreach (self::yieldNavigation($navItem) as $name => $url) { + $origins[] = [ + 'name' => $name, + 'url' => $url->getScheme() . '://' . $url->getHost(), + 'reason' => [ 'type' => 'navigation', - 'name' => $itemConfig->get('name'), - 'shared' => false, + 'name' => $name, + 'parent' => $navItem->getName(), + 'navType' => $type, ], ]; } } - $configShared = Config::navigation($type); - $configShared->getConfigObject()->setKeyColumn('name'); - foreach ($configShared->select() as $itemConfig) { - if (Icinga::app()->hasAccessToSharedNavigationItem($itemConfig, $config) && - $itemConfig->get("target", "") !== "_blank" - ) { - $menuItems[] = [ - "name" => $itemConfig->get('name'), - "url" => $itemConfig->get('url'), - "reason" => [ - 'type' => 'navigation', - 'name' => $itemConfig->get('name'), - 'shared' => true, - ], - ]; - } + } + + return $origins; + } + + protected static function yieldNavigation(NavigationItem $item): Generator + { + if ($item->hasChildren()) { + foreach ($item as $child) { + yield from self::yieldNavigation($child); + } + } else { + $url = $item->getUrl(); + if ($url === null) { + return; + } + if ($item->getTarget() !== '_blank' && $url->isExternal()) { + yield $item->getName() => $item->getUrl(); } } - return $menuItems; } /** * Fetches all dashlets for the current user that have an external URL. * * @return array A list of dashlets with their names and absolute URLs. - * // returns [['name' => 'Dashlet Name', 'url' => 'https://external.dashlet.com'], ...] + * // returns [ ['name' => 'Dashlet Name', 'url' => 'https://external.dashlet.com', 'reason' => [...] ], ...] */ protected static function fetchDashletsItems(): array { From 43f78a7f24e8d2b9b992db2a42a11492b1bd8ad5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 16:10:35 +0100 Subject: [PATCH 19/96] Add info for navigation items --- library/Icinga/Web/Widget/CspConfigurationTable.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index fb0e243de6..2bb782f9f2 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -30,6 +30,9 @@ protected function assemble(): void $reason = $directiveGroup['reason']; $type = $reason['type']; $info = match ($type) { + 'navigation' => $reason['navType'] + . '/' . ($reason['parent'] !== null ? ($reason['parent'] . '/') : '') + . $reason['name'], 'dashlet' => $reason['pane'] . '/' . $reason['dashlet'], 'hook' => $reason['hook'], default => '-', From 45c876403dba724c192736211beff96fcbd8165a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 13 Mar 2026 08:08:53 +0100 Subject: [PATCH 20/96] Create style nonce before trying to display the automatic csp --- application/forms/Config/General/CspConfigForm.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 670bfc953f..d02ad2af33 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -82,6 +82,7 @@ protected function assemble(): void $customCspElement = $this->getElement('custom_csp'); if ($this->hasBeenSubmitted()) { if (! $useCustomCsp) { + Csp::createNonce(); $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); } return; @@ -96,6 +97,7 @@ protected function assemble(): void } } else { $this->getElement('hidden_custom_csp')->setValue($this->getValue('custom_csp')); + Csp::createNonce(); $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); } } From 6130964cbdb1c13934dffe0ffb559ba3fe3348a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 13 Mar 2026 08:17:12 +0100 Subject: [PATCH 21/96] Add GPLv2+ license headers --- application/forms/Config/General/CspConfigForm.php | 2 ++ library/Icinga/Application/Hook/CspDirectiveHook.php | 3 ++- library/Icinga/Web/Widget/CspConfigurationTable.php | 2 ++ 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index d02ad2af33..0292875e87 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -1,5 +1,7 @@ Date: Fri, 13 Mar 2026 11:10:49 +0100 Subject: [PATCH 22/96] Use a callout to display a warning message that is more obvious Requires ipl-web#358 --- application/forms/Config/General/CspConfigForm.php | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 0292875e87..1062a37042 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -7,9 +7,11 @@ use Icinga\Application\Config; use Icinga\Util\Csp; use Icinga\Web\Session; +use ipl\Web\Common\CalloutType; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Common\FormUid; use ipl\Web\Compat\CompatForm; +use ipl\Web\Widget\Callout; class CspConfigForm extends CompatForm { @@ -47,8 +49,6 @@ protected function assemble(): void 'label' => $this->translate('Enable Custom CSP'), 'description' => $this->translate( 'Specify whether to use a custom, user provided, string as the CSP-Header.' - . ' If you decide to provide your own CSP-Header, you are entirely responsible for keeping it' - . ' up-to-date.' ), 'class' => 'autosubmit', ] @@ -58,6 +58,16 @@ protected function assemble(): void $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; if ($useCustomCsp) { + $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. If you do not know what you are doing, please leave this checkbox unchecked.' + ), + $this->translate('Warning: Use at your own risk!'), + ))->setFormElement()); + $this->addElement('textarea', 'custom_csp', [ 'label' => $this->translate('Custom CSP'), 'description' => $this->translate( From 0ee410dde7e60673344ce2e1974c411342e72f10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 13 Mar 2026 13:37:52 +0100 Subject: [PATCH 23/96] Simplify the way CSP items are collected for dashlets --- library/Icinga/Util/Csp.php | 99 ++++++++++--------------------------- 1 file changed, 27 insertions(+), 72 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index fe24e826dd..bde131e511 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -67,40 +67,7 @@ public static function isCspEnabled(): bool public static function collectContentSecurityPolicyDirectives(): array { - $policyDirectives = []; - - // Whitelist the hosts in the custom NavigationItems configured for the user, - // so that the iframes can be rendered properly. - /** @var ConfigObject[] $navigationItems */ - $navigationItems = self::fetchDashletNavigationItemConfigs(); - foreach ($navigationItems as $navigationItem) { - $errorSource = sprintf("Navigation item %s", $navigationItem['name']); - - $host = parse_url($navigationItem["url"], PHP_URL_HOST); - // Make sure $url is actually valid; - if (filter_var($navigationItem["url"], FILTER_VALIDATE_URL) === false) { - Logger::debug("$errorSource: Skipping invalid url: $host"); - continue; - } - - $scheme = parse_url($navigationItem["url"], PHP_URL_SCHEME); - - if ($host === null) { - continue; - } - - $policy = $host; - if ($scheme !== null) { - $policy = "$scheme://$host"; - } - - $policyDirectives[] = [ - 'directives' => [ - 'frame-src' => [$policy], - ], - 'reason' => $navigationItem['reason'], - ]; - } + $policyDirectives = self::fetchDashletNavigationItemConfigs(); // Allow modules to add their own csp directives in a limited fashion. foreach (CspDirectiveHook::all() as $hook) { @@ -286,7 +253,6 @@ protected static function getInstance(): self * Fetches and merges configurations for navigation menu items and dashlets. * * @return array An array containing both navigation items and dashlet configurations. - * // returns [['name' => 'Item Name', 'url' => 'https://example.com'], ...] */ protected static function fetchDashletNavigationItemConfigs(): array { @@ -302,8 +268,7 @@ protected static function fetchDashletNavigationItemConfigs(): array * Iterates through all registered navigation types, loads both user-specific * and shared configurations, and returns a list of menu items. * - * @return array Each item is an associative array with 'name' and 'url' keys. - * Example: [ ['name' => 'Home', 'url' => '/', 'reason' => [...] ], ... ] + * @return array A list of CSP directives, one for each navigation-item that has an external URL. */ protected static function fetchNavigationItems(): array { @@ -319,14 +284,15 @@ protected static function fetchNavigationItems(): array foreach ($navigation->load($type) as $navItem) { foreach (self::yieldNavigation($navItem) as $name => $url) { $origins[] = [ - 'name' => $name, - 'url' => $url->getScheme() . '://' . $url->getHost(), + 'directives' => [ + 'frame-src' => [$url->getScheme() . '://' . $url->getHost()], + ], 'reason' => [ 'type' => 'navigation', 'name' => $name, 'parent' => $navItem->getName(), 'navType' => $type, - ], + ] ]; } } @@ -355,60 +321,49 @@ protected static function yieldNavigation(NavigationItem $item): Generator /** * Fetches all dashlets for the current user that have an external URL. * - * @return array A list of dashlets with their names and absolute URLs. - * // returns [ ['name' => 'Dashlet Name', 'url' => 'https://external.dashlet.com', 'reason' => [...] ], ...] + * @return array A list of CSP directives, one for each dashlet that has an external URL. */ protected static function fetchDashletsItems(): array { $user = Auth::getInstance()->getUser(); - $dashlets = []; + $origins = []; if ($user === null) { - return $dashlets; + return $origins; } $dashboard = new Dashboard(); $dashboard->setUser($user); $dashboard->load(); + /** @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; } - $externalUrl = $url->getParam("url"); - if ($externalUrl !== null && filter_var($externalUrl, FILTER_VALIDATE_URL) !== false) { - $dashlets[] = [ - "name" => $dashlet->getName(), - "url" => $externalUrl, - "reason" => [ - "type" => "dashlet", - "user" => $user->getUsername(), - "pane" => $pane->getName(), - "dashlet" => $dashlet->getName(), - ], - ]; + $absoluteUrl = $url->isExternal() + ? $url->getAbsoluteUrl() + : $url->getParam('url'); + if ($absoluteUrl === null || filter_var($absoluteUrl, FILTER_VALIDATE_URL) === false) { continue; } - if ($url->isExternal()) { - $absoluteUrl = $url->getAbsoluteUrl(); - if (filter_var($absoluteUrl, FILTER_VALIDATE_URL) !== false) { - $dashlets[] = [ - "name" => $dashlet->getName(), - "url" => $absoluteUrl, - "reason" => [ - "type" => "dashlet-iframe", - "user" => $user->getUsername(), - "pane" => $pane->getName(), - "dashlet" => $dashlet->getName(), - ], - ]; - } - } + $origins[] = [ + 'directives' => [ + 'frame-src' => [$absoluteUrl], + ], + 'reason' => [ + 'type' => 'dashlet', + 'user' => $user->getUsername(), + 'pane' => $pane->getName(), + 'dashlet' => $dashlet->getName(), + ] + ]; } } - return $dashlets; + return $origins; } } From fc07616415ec0765fd4847de128e35a3e1d258fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 13 Mar 2026 14:06:58 +0100 Subject: [PATCH 24/96] Use generators instead of iterating over arrays multiple times --- library/Icinga/Util/Csp.php | 171 ++++++++++++++++++------------------ 1 file changed, 87 insertions(+), 84 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index bde131e511..b7a9aebfff 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -11,7 +11,6 @@ use Icinga\Application\Icinga; use Icinga\Application\Logger; use Icinga\Authentication\Auth; -use Icinga\Data\ConfigObject; use Icinga\Web\Navigation\Navigation; use Icinga\Web\Navigation\NavigationItem; use Icinga\Web\Response; @@ -67,51 +66,13 @@ public static function isCspEnabled(): bool public static function collectContentSecurityPolicyDirectives(): array { - $policyDirectives = self::fetchDashletNavigationItemConfigs(); - - // Allow modules to add their own csp directives in a limited fashion. - foreach (CspDirectiveHook::all() as $hook) { - $directives = []; - try { - foreach ($hook->getCspDirectives() as $directive => $policies) { - // policy names contain only lowercase letters and '-'. Reject anything else. - if (! preg_match('|^[a-z\-]+$|', $directive)) { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Invalid CSP directive found: $directive"); - continue; - } - - // The default-src can only ever be 'self'. Disallow any updates to it. - if ($directive === "default-src") { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Changing default-src is forbidden."); - continue; - } - - if (count($policies) === 0) { - continue; - } - - $directives[$directive] = $policies; - } - - if (count($directives) === 0) { - continue; - } - - $policyDirectives[] = [ - "directives" => $directives, - "reason" => [ - "type" => "hook", - "hook" => get_class($hook), - ], - ]; - } catch (Throwable $e) { - Logger::error('Failed to CSP hook on request: %s', $e); - } - } - - return $policyDirectives; + // Create an array here because system origins should always come first. + return array_merge( + iterator_to_array(self::yieldSystemOrigins()), + iterator_to_array(self::yieldNavigationOrigins()), + iterator_to_array(self::yieldDashletItems()), + iterator_to_array(self::yieldModuleOrigins()), + ); } /** @@ -156,20 +117,7 @@ protected static function getCustomContentSecurityPolicy(): ?string */ public static function getAutomaticContentSecurityPolicy(): string { - $csp = static::getInstance(); - - if (empty($csp->styleNonce)) { - throw new RuntimeException('No nonce set for CSS'); - } - - // These are the default directives that should always be enforced. 'self' is valid for all - // directives and will therefore not be listed here. - $cspDirectives = [ - 'style-src' => ["'nonce-{$csp->styleNonce}'"], - 'font-src' => ["data:"], - 'img-src' => ["data:"], - 'frame-src' => [], - ]; + $cspDirectives = []; $policyDirectives = self::collectContentSecurityPolicyDirectives(); @@ -186,7 +134,7 @@ public static function getAutomaticContentSecurityPolicy(): string foreach ($cspDirectives as $directive => $policyDirectives) { if (! empty($policyDirectives)) { $header .= ' ' . - implode(' ', array_merge([$directive, "'self'"], array_unique($policyDirectives))) + implode(' ', array_merge([$directive], array_unique($policyDirectives))) . ';'; } } @@ -249,17 +197,77 @@ protected static function getInstance(): self return static::$instance; } - /** - * Fetches and merges configurations for navigation menu items and dashlets. - * - * @return array An array containing both navigation items and dashlet configurations. - */ - protected static function fetchDashletNavigationItemConfigs(): array + protected static function yieldSystemOrigins(): Generator { - return array_merge( - self::fetchNavigationItems(), - self::fetchDashletsItems(), - ); + $csp = static::getInstance(); + + if (empty($csp->styleNonce)) { + throw new RuntimeException('No nonce set for CSS'); + } + + $items = [ + 'default-src' => ["'self'"], + 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], + 'font-src' => ["'self'", "data:"], + 'img-src' => ["'self'", "data:"], + 'frame-src' => ["'self'"], + ]; + + foreach ($items as $directive => $policies) { + yield [ + 'directives' => [ + $directive => $policies, + ], + 'reason' => [ + 'type' => 'system', + ] + ]; + } + } + + protected static function yieldModuleOrigins(): Generator + { + // Allow modules to add their own csp directives in a limited fashion. + foreach (CspDirectiveHook::all() as $hook) { + $directives = []; + try { + foreach ($hook->getCspDirectives() as $directive => $policies) { + // policy names contain only lowercase letters and '-'. Reject anything else. + if (! preg_match('|^[a-z\-]+$|', $directive)) { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Invalid CSP directive found: $directive"); + continue; + } + + // The default-src can only ever be 'self'. Disallow any updates to it. + if ($directive === "default-src") { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Changing default-src is forbidden."); + continue; + } + + if (count($policies) === 0) { + continue; + } + + $directives[$directive] = $policies; + } + + if (count($directives) === 0) { + continue; + } + + yield [ + "directives" => $directives, + "reason" => [ + "type" => "hook", + "hook" => get_class($hook), + ], + ]; + } catch (Throwable $e) { + Logger::error('Failed to CSP hook on request: %s', $e); + } + } } /** @@ -268,22 +276,21 @@ protected static function fetchDashletNavigationItemConfigs(): array * Iterates through all registered navigation types, loads both user-specific * and shared configurations, and returns a list of menu items. * - * @return array A list of CSP directives, one for each navigation-item that has an external URL. + * @return Generator A list of CSP directives, one for each navigation-item that has an external URL. */ - protected static function fetchNavigationItems(): array + protected static function yieldNavigationOrigins(): Generator { $auth = Auth::getInstance(); if (! $auth->isAuthenticated()) { - return []; + return; } - $origins = []; $navigationType = Navigation::getItemTypeConfiguration(); foreach ($navigationType as $type => $_) { $navigation = new Navigation(); foreach ($navigation->load($type) as $navItem) { foreach (self::yieldNavigation($navItem) as $name => $url) { - $origins[] = [ + yield [ 'directives' => [ 'frame-src' => [$url->getScheme() . '://' . $url->getHost()], ], @@ -297,8 +304,6 @@ protected static function fetchNavigationItems(): array } } } - - return $origins; } protected static function yieldNavigation(NavigationItem $item): Generator @@ -321,14 +326,13 @@ protected static function yieldNavigation(NavigationItem $item): Generator /** * Fetches all dashlets for the current user that have an external URL. * - * @return array A list of CSP directives, one for each dashlet that has an external URL. + * @return Generator A list of CSP directives, one for each dashlet that has an external URL. */ - protected static function fetchDashletsItems(): array + protected static function yieldDashletItems(): Generator { $user = Auth::getInstance()->getUser(); - $origins = []; if ($user === null) { - return $origins; + return; } $dashboard = new Dashboard(); @@ -351,7 +355,7 @@ protected static function fetchDashletsItems(): array continue; } - $origins[] = [ + yield [ 'directives' => [ 'frame-src' => [$absoluteUrl], ], @@ -364,6 +368,5 @@ protected static function fetchDashletsItems(): array ]; } } - return $origins; } } From 200789c9f73fb476b1c489960c85d58711c8a4b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 13 Mar 2026 14:42:04 +0100 Subject: [PATCH 25/96] Write documentation & rename Items to Origins --- .../Application/Hook/CspDirectiveHook.php | 6 ++-- library/Icinga/Util/Csp.php | 35 ++++++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspDirectiveHook.php index 93af328501..58351fc069 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspDirectiveHook.php @@ -10,8 +10,8 @@ abstract class CspDirectiveHook { /** * Allow the module to provide custom directives for the CSP header. The return value should be an array - * with directive as the key and the policies in an array as the value. The valid values can either be - * a concrete host, whitelisting subdomains for hosts or a custom nonce for that module. + * with a directive as the key and the policies in an array as the value. The valid values can either be + * a concrete host, allowlisting subdomains for hosts or custom nonce for that module. * * Example: [ 'img-src' => [ 'https://*.media.tumblr.com', 'https://http.cat/' ] ] * @@ -30,7 +30,7 @@ public static function all(): array } /** - * Register the class as a RequestHook implementation + * Register the class as a CspDirectiveHook implementation * * Call this method on your implementation during module initialization to make Icinga Web aware of your hook. */ diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index b7a9aebfff..45a4bbfe0c 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -64,13 +64,19 @@ public static function isCspEnabled(): bool return Config::app()->get('security', 'use_strict_csp', 'n') === 'y'; } + /** + * Collects all CSP directives in an array where the system defaults are first. + * This is done over using a Generator because the order of the directives is important. + * + * @return array the list of CSP directives + */ public static function collectContentSecurityPolicyDirectives(): array { // Create an array here because system origins should always come first. return array_merge( iterator_to_array(self::yieldSystemOrigins()), iterator_to_array(self::yieldNavigationOrigins()), - iterator_to_array(self::yieldDashletItems()), + iterator_to_array(self::yieldDashletOrigins()), iterator_to_array(self::yieldModuleOrigins()), ); } @@ -92,7 +98,13 @@ public static function getContentSecurityPolicy(): string return self::getAutomaticContentSecurityPolicy(); } - protected static function getCustomContentSecurityPolicy(): ?string + /** + * Get the custom Content-Security-Policy set in the config. + * This method automatically replaces new-lines and the {style_nonce} placeholder with the generated nonce. + * + * @return string Returns the custom CSP header value. + */ + protected static function getCustomContentSecurityPolicy(): string { $csp = static::getInstance(); @@ -113,7 +125,6 @@ protected static function getCustomContentSecurityPolicy(): ?string * * @return string Returns the generated header value. * @throws RuntimeException If no nonce set for CSS - * */ public static function getAutomaticContentSecurityPolicy(): string { @@ -197,6 +208,12 @@ protected static function getInstance(): self return static::$instance; } + /** + * Yields the system origins. + * These directives should always be added first. + * + * @return Generator + */ protected static function yieldSystemOrigins(): Generator { $csp = static::getInstance(); @@ -225,6 +242,10 @@ protected static function yieldSystemOrigins(): Generator } } + /** + * Yield all CSP directives from modules. See {@see CspDirectiveHook} for details. + * @return Generator + */ protected static function yieldModuleOrigins(): Generator { // Allow modules to add their own csp directives in a limited fashion. @@ -306,6 +327,12 @@ protected static function yieldNavigationOrigins(): Generator } } + /** + * Recursively yield all navigation items that have an external URL. + * + * @param NavigationItem $item The top-level navigation item to start from. + * @return Generator + */ protected static function yieldNavigation(NavigationItem $item): Generator { if ($item->hasChildren()) { @@ -328,7 +355,7 @@ protected static function yieldNavigation(NavigationItem $item): Generator * * @return Generator A list of CSP directives, one for each dashlet that has an external URL. */ - protected static function yieldDashletItems(): Generator + protected static function yieldDashletOrigins(): Generator { $user = Auth::getInstance()->getUser(); if ($user === null) { From 88903b0cf44e1b00838706c3fa4fd8919f145296 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 13 Mar 2026 15:29:14 +0100 Subject: [PATCH 26/96] Remove passive agressive note to admins --- application/forms/Config/General/CspConfigForm.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 1062a37042..27a5595528 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -63,7 +63,7 @@ protected function assemble(): void $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. If you do not know what you are doing, please leave this checkbox unchecked.' + . ' and secure.', ), $this->translate('Warning: Use at your own risk!'), ))->setFormElement()); From 48b9983d02fcb8a75a66b07af5a5fcd451f89b2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 13 Mar 2026 16:00:12 +0100 Subject: [PATCH 27/96] Display module name instead of hook class --- library/Icinga/Util/Csp.php | 9 +++++---- library/Icinga/Web/Widget/CspConfigurationTable.php | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 45a4bbfe0c..9dc7edf191 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -6,6 +6,7 @@ namespace Icinga\Util; use Generator; +use Icinga\Application\ClassLoader; use Icinga\Application\Config; use Icinga\Application\Hook\CspDirectiveHook; use Icinga\Application\Icinga; @@ -279,10 +280,10 @@ protected static function yieldModuleOrigins(): Generator } yield [ - "directives" => $directives, - "reason" => [ - "type" => "hook", - "hook" => get_class($hook), + 'directives' => $directives, + 'reason' => [ + 'type' => 'module', + 'module' => ClassLoader::extractModuleName(get_class($hook)), ], ]; } catch (Throwable $e) { diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index c23fe3bbed..ad5b4b8827 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -36,7 +36,7 @@ protected function assemble(): void . '/' . ($reason['parent'] !== null ? ($reason['parent'] . '/') : '') . $reason['name'], 'dashlet' => $reason['pane'] . '/' . $reason['dashlet'], - 'hook' => $reason['hook'], + 'module' => $reason['module'], default => '-', }; foreach ($directiveGroup['directives'] as $directive => $policies) { From 4a4130a2095fb79d07378943e887e026c0a77907 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 16 Mar 2026 08:26:07 +0100 Subject: [PATCH 28/96] Apply code review changes --- .../Config/General/ApplicationConfigForm.php | 2 -- .../Web/Widget/CspConfigurationTable.php | 20 +++++++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/application/forms/Config/General/ApplicationConfigForm.php b/application/forms/Config/General/ApplicationConfigForm.php index 47a21c8da3..d33b865822 100644 --- a/application/forms/Config/General/ApplicationConfigForm.php +++ b/application/forms/Config/General/ApplicationConfigForm.php @@ -6,10 +6,8 @@ namespace Icinga\Forms\Config\General; use Icinga\Application\Icinga; -use Icinga\Authentication\Auth; use Icinga\Data\ResourceFactory; use Icinga\Web\Form; -use Icinga\Util\Csp; /** * Configuration form for general application options diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index ad5b4b8827..1696096726 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -19,11 +19,11 @@ public function __construct() protected function assemble(): void { - $this->add(self::tr([ - self::th($this->translate('Type')), - self::th($this->translate('Info')), - self::th($this->translate('Directive')), - self::th($this->translate('Value')), + $this->add(static::tr([ + static::th($this->translate('Type')), + static::th($this->translate('Info')), + static::th($this->translate('Directive')), + static::th($this->translate('Value')), ])); $policyDirectives = Csp::collectContentSecurityPolicyDirectives(); @@ -40,11 +40,11 @@ protected function assemble(): void default => '-', }; foreach ($directiveGroup['directives'] as $directive => $policies) { - $this->add(self::tr([ - self::td($type), - self::td($info), - self::td($directive), - self::td(join(', ', $policies)), + $this->add(static::tr([ + static::td($type), + static::td($info), + static::td($directive), + static::td(join(', ', $policies)), ])); } } From dacdf7f428575b52619a40d5a69b5371192767a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 16 Mar 2026 08:36:53 +0100 Subject: [PATCH 29/96] Hide unused form elements and table if CSP is disabled --- application/controllers/ConfigController.php | 7 +- .../forms/Config/General/CspConfigForm.php | 111 ++++++++++-------- 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 145ccdd82e..0aeaff40b1 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -9,6 +9,7 @@ use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; use Icinga\Forms\Config\General\CspConfigForm; +use Icinga\Util\Csp; use Icinga\Web\Widget\CspConfigurationTable; use InvalidArgumentException; use Icinga\Application\Config; @@ -117,7 +118,11 @@ public function generalAction() $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; - $this->view->cspTable = (new CspConfigurationTable())->render(); + if ($cspForm->getPopulatedValue('use_strict_csp', 'n') === 'y') { + $this->view->cspTable = (new CspConfigurationTable())->render(); + } else { + $this->view->cspTable = ''; + } $this->createApplicationTabs()->activate('general'); } diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 27a5595528..ee8938d307 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -34,63 +34,71 @@ protected function assemble(): void 'checkbox', 'use_strict_csp', [ - 'label' => $this->translate('Enable strict CSP'), - 'description' => $this->translate( + 'label' => $this->translate('Enable strict CSP'), + 'description' => $this->translate( 'Set whether to use strict content security policy (CSP).' - . ' This setting helps to protect from cross-site scripting (XSS).' - ), - ], - ); - - $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.' + . ' This setting helps to protect from cross-site scripting (XSS).', ), 'class' => 'autosubmit', - ] + ], ); - $this->addElement('hidden', 'hidden_custom_csp'); - - $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; - if ($useCustomCsp) { - $this->addHtml((new Callout( - CalloutType::Warning, - $this->translate( - 'Be aware that the custom CSP-Header completely overrides the automatically generated one.' - . ' This means that you are solely responsible for keeping the custom CSP-Header up-to-date' - . ' and secure.', - ), - $this->translate('Warning: Use at your own risk!'), - ))->setFormElement()); - - $this->addElement('textarea', 'custom_csp', [ - 'label' => $this->translate('Custom CSP'), - '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.' - ), - 'disabled' => false, - ]); - } else { - $this->addElement('textarea', 'custom_csp', [ - 'label' => $this->translate('Generated CSP'), - 'description' => $this->translate( - 'This is the current CSP-Header. You can always safely go back to this by disabling the' - . ' Enable Custom CSP checkbox above.' - ), - 'disabled' => true, - ]); + $useCsp = $this->getPopulatedValue('use_strict_csp', 'n') === 'y'; + if ($useCsp) { + $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', + ], + ); + + $this->addElement('hidden', 'hidden_custom_csp'); + + $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; + if ($useCustomCsp) { + $this->addHtml((new Callout( + CalloutType::Warning, + $this->translate( + 'Be aware that the custom CSP-Header completely overrides the automatically generated one.' + . ' This means that you are solely responsible for keeping the custom CSP-Header up-to-date' + . ' and secure.', + ), + $this->translate('Warning: Use at your own risk!'), + ))->setFormElement()); + + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate('Custom CSP'), + '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.', + ), + 'disabled' => false, + ]); + } else { + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate('Generated CSP'), + 'description' => $this->translate( + 'This is the current CSP-Header. You can always safely go back to this by disabling the' + . ' Enable Custom CSP checkbox above.', + ), + 'disabled' => true, + ]); + } } $this->addElement('submit', 'submit', [ 'label' => t('Save changes'), ]); + if (! $useCsp) { + return; + } + $customCspElement = $this->getElement('custom_csp'); if ($this->hasBeenSubmitted()) { if (! $useCustomCsp) { @@ -120,10 +128,13 @@ protected function onSuccess(): void $section = $config->getSection('security'); $section['use_strict_csp'] = $this->getValue('use_strict_csp'); - $section['use_custom_csp'] = $this->getValue('use_custom_csp'); - $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; - if ($useCustomCsp) { - $section['custom_csp'] = $this->getValue('custom_csp'); + $useCsp = $this->getPopulatedValue('use_strict_csp', 'n') === 'y'; + if ($useCsp) { + $section['use_custom_csp'] = $this->getValue('use_custom_csp'); + $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; + if ($useCustomCsp) { + $section['custom_csp'] = $this->getValue('custom_csp'); + } } $config->setSection('security', $section); From 2c4b8d260ae4093dc396dcad26649d2277d1a9f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 16 Mar 2026 08:59:02 +0100 Subject: [PATCH 30/96] Automatically reload the window on form success if CSP is active --- application/controllers/ConfigController.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 0aeaff40b1..42aa195dcc 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -9,7 +9,6 @@ use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; use Icinga\Forms\Config\General\CspConfigForm; -use Icinga\Util\Csp; use Icinga\Web\Widget\CspConfigurationTable; use InvalidArgumentException; use Icinga\Application\Config; @@ -29,6 +28,7 @@ use Icinga\Web\Notification; use Icinga\Web\Url; use Icinga\Web\Widget; +use ipl\Html\Form; /** * Application and module configuration @@ -115,6 +115,13 @@ public function generalAction() 'use_strict_csp' => $config->get('security', 'use_strict_csp'), 'use_custom_csp' => $config->get('security', 'use_custom_csp'), ]); + + $cspForm->on(Form::ON_SUBMIT, function (Form $form) use ($config) { + $useCsp = $form->getPopulatedValue('use_strict_csp', 'n') === 'y'; + if ($useCsp) { + $this->getResponse()->setReloadWindow(true); + } + }); $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; From 749a8908f6883067e8e042000133e4673463312f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 16 Mar 2026 09:55:12 +0100 Subject: [PATCH 31/96] Change URLs in method documentation CspDirectiveHook::getCspDirectives() --- library/Icinga/Application/Hook/CspDirectiveHook.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspDirectiveHook.php index 58351fc069..e592cdc86b 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspDirectiveHook.php @@ -6,6 +6,10 @@ use Icinga\Application\Hook; +/** + * Allow modules to provide custom CSP directives. + * This hook is only used if the CSP header is enabled. + */ abstract class CspDirectiveHook { /** @@ -13,7 +17,7 @@ abstract class CspDirectiveHook * with a directive as the key and the policies in an array as the value. The valid values can either be * a concrete host, allowlisting subdomains for hosts or custom nonce for that module. * - * Example: [ 'img-src' => [ 'https://*.media.tumblr.com', 'https://http.cat/' ] ] + * Example: [ 'img-src' => [ 'https://*.icinga.com', 'https://example.com/' ] ] * * @return array The CSP directives are the keys and the policies the values. */ From dabf1f8a2ff85bc5560175f29342a0f9c60322a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 16 Mar 2026 10:09:01 +0100 Subject: [PATCH 32/96] Use getValue instead of getPopulatedValue --- application/controllers/ConfigController.php | 7 ++++--- application/forms/Config/General/CspConfigForm.php | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 42aa195dcc..c38887147d 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -9,6 +9,7 @@ use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; use Icinga\Forms\Config\General\CspConfigForm; +use Icinga\Util\Csp; use Icinga\Web\Widget\CspConfigurationTable; use InvalidArgumentException; use Icinga\Application\Config; @@ -112,12 +113,12 @@ public function generalAction() $config = Config::app(); $cspForm = new CspConfigForm($config); $cspForm->populate([ - 'use_strict_csp' => $config->get('security', 'use_strict_csp'), + 'use_strict_csp' => Csp::isCspEnabled(), 'use_custom_csp' => $config->get('security', 'use_custom_csp'), ]); $cspForm->on(Form::ON_SUBMIT, function (Form $form) use ($config) { - $useCsp = $form->getPopulatedValue('use_strict_csp', 'n') === 'y'; + $useCsp = $form->getValue('use_strict_csp') === 'y'; if ($useCsp) { $this->getResponse()->setReloadWindow(true); } @@ -125,7 +126,7 @@ public function generalAction() $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; - if ($cspForm->getPopulatedValue('use_strict_csp', 'n') === 'y') { + if ($cspForm->getValue('use_strict_csp') === 'y') { $this->view->cspTable = (new CspConfigurationTable())->render(); } else { $this->view->cspTable = ''; diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index ee8938d307..77ebccfa4e 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -43,7 +43,7 @@ protected function assemble(): void ], ); - $useCsp = $this->getPopulatedValue('use_strict_csp', 'n') === 'y'; + $useCsp = $this->getValue('use_strict_csp') === 'y'; if ($useCsp) { $this->addElement( 'checkbox', @@ -59,7 +59,7 @@ protected function assemble(): void $this->addElement('hidden', 'hidden_custom_csp'); - $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; + $useCustomCsp = $this->getValue('use_custom_csp') === 'y'; if ($useCustomCsp) { $this->addHtml((new Callout( CalloutType::Warning, @@ -109,7 +109,7 @@ protected function assemble(): void } if ($useCustomCsp) { - $value = $this->getPopulatedValue('hidden_custom_csp'); + $value = $this->getValue('hidden_custom_csp'); if (! empty($value)) { $customCspElement->setValue($value); } else { From 46019457a6fa4962433f83388f586794ff7a8ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 16 Mar 2026 10:08:49 +0100 Subject: [PATCH 33/96] Handle update to new value gracefully --- library/Icinga/Util/Csp.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 9dc7edf191..8ce14b218d 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -62,7 +62,9 @@ public static function addHeader(Response $response): void public static function isCspEnabled(): bool { - return Config::app()->get('security', 'use_strict_csp', 'n') === 'y'; + $value = Config::app()->get('security', 'use_strict_csp', 'n'); + + return in_array($value, ['y', '1']); } /** @@ -118,6 +120,7 @@ protected static function getCustomContentSecurityPolicy(): string $customCsp = str_replace("\r\n", ' ', $customCsp); $customCsp = str_replace("\n", ' ', $customCsp); $customCsp = str_replace('{style_nonce}', "'nonce-{$csp->styleNonce}'", $customCsp); + return $customCsp; } From 841a30a1d20b09b17dbcbce8f2e31642eeb31110 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 16 Mar 2026 10:25:41 +0100 Subject: [PATCH 34/96] Use a hidden element with the same name to store the custom value --- application/controllers/ConfigController.php | 1 + .../forms/Config/General/CspConfigForm.php | 41 ++++--------------- 2 files changed, 8 insertions(+), 34 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index c38887147d..3c07ec2a4f 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -115,6 +115,7 @@ public function generalAction() $cspForm->populate([ 'use_strict_csp' => Csp::isCspEnabled(), 'use_custom_csp' => $config->get('security', 'use_custom_csp'), + 'custom_csp' => $config->get('security', 'custom_csp'), ]); $cspForm->on(Form::ON_SUBMIT, function (Form $form) use ($config) { diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 77ebccfa4e..ebbc9e1c94 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -43,8 +43,7 @@ protected function assemble(): void ], ); - $useCsp = $this->getValue('use_strict_csp') === 'y'; - if ($useCsp) { + if ($this->getValue('use_strict_csp') === 'y') { $this->addElement( 'checkbox', 'use_custom_csp', @@ -57,10 +56,7 @@ protected function assemble(): void ], ); - $this->addElement('hidden', 'hidden_custom_csp'); - - $useCustomCsp = $this->getValue('use_custom_csp') === 'y'; - if ($useCustomCsp) { + if ($this->getValue('use_custom_csp') === 'y') { $this->addHtml((new Callout( CalloutType::Warning, $this->translate( @@ -77,16 +73,19 @@ protected function assemble(): void 'Set a custom CSP-Header. This completely overrides the automatically generated one.' . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.', ), - 'disabled' => false, ]); } else { - $this->addElement('textarea', 'custom_csp', [ + $this->addElement('hidden', 'custom_csp'); + + Csp::createNonce(); + $this->addElement('textarea', 'generated_csp', [ 'label' => $this->translate('Generated CSP'), 'description' => $this->translate( 'This is the current CSP-Header. You can always safely go back to this by disabling the' . ' Enable Custom CSP checkbox above.', ), 'disabled' => true, + 'value' => Csp::getAutomaticContentSecurityPolicy(), ]); } } @@ -94,32 +93,6 @@ protected function assemble(): void $this->addElement('submit', 'submit', [ 'label' => t('Save changes'), ]); - - if (! $useCsp) { - return; - } - - $customCspElement = $this->getElement('custom_csp'); - if ($this->hasBeenSubmitted()) { - if (! $useCustomCsp) { - Csp::createNonce(); - $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); - } - return; - } - - if ($useCustomCsp) { - $value = $this->getValue('hidden_custom_csp'); - if (! empty($value)) { - $customCspElement->setValue($value); - } else { - $customCspElement->setValue($this->config->get('security', 'custom_csp')); - } - } else { - $this->getElement('hidden_custom_csp')->setValue($this->getValue('custom_csp')); - Csp::createNonce(); - $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); - } } protected function onSuccess(): void From c6d06736084a9351a9e2e4abc58bf33f50cde70f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 16 Mar 2026 11:23:09 +0100 Subject: [PATCH 35/96] Remove superfluous mentions of CSP inside the Csp class --- application/controllers/ConfigController.php | 2 +- .../forms/Config/General/CspConfigForm.php | 2 +- library/Icinga/Util/Csp.php | 23 +++++++++---------- library/Icinga/Web/Response.php | 2 +- .../Web/Widget/CspConfigurationTable.php | 2 +- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 3c07ec2a4f..6107b1eade 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -113,7 +113,7 @@ public function generalAction() $config = Config::app(); $cspForm = new CspConfigForm($config); $cspForm->populate([ - 'use_strict_csp' => Csp::isCspEnabled(), + 'use_strict_csp' => Csp::isEnabled(), 'use_custom_csp' => $config->get('security', 'use_custom_csp'), 'custom_csp' => $config->get('security', 'custom_csp'), ]); diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index ebbc9e1c94..f984987944 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -85,7 +85,7 @@ protected function assemble(): void . ' Enable Custom CSP checkbox above.', ), 'disabled' => true, - 'value' => Csp::getAutomaticContentSecurityPolicy(), + 'value' => Csp::getAutomaticHeaderValue(), ]); } } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 8ce14b218d..92cc3d662e 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -56,11 +56,11 @@ private function __construct() */ public static function addHeader(Response $response): void { - $header = static::getContentSecurityPolicy(); + $header = static::getHeader(); $response->setHeader('Content-Security-Policy', $header, true); } - public static function isCspEnabled(): bool + public static function isEnabled(): bool { $value = Config::app()->get('security', 'use_strict_csp', 'n'); @@ -73,7 +73,7 @@ public static function isCspEnabled(): bool * * @return array the list of CSP directives */ - public static function collectContentSecurityPolicyDirectives(): array + public static function collectDirectives(): array { // Create an array here because system origins should always come first. return array_merge( @@ -85,20 +85,19 @@ public static function collectContentSecurityPolicyDirectives(): array } /** - * Get the Content-Security-Policy. + * Get the Content-Security-Policy header. * - * @return string Returns the generated header value. + * @return string Returns the CSP header for this request. * @throws RuntimeException If no nonce set for CSS - * */ - public static function getContentSecurityPolicy(): string + public static function getHeader(): string { $config = Config::app(); if ($config->get('security', 'use_custom_csp', 'y') === 'y') { - return self::getCustomContentSecurityPolicy(); + return self::getCustomHeaderValue(); } - return self::getAutomaticContentSecurityPolicy(); + return self::getAutomaticHeaderValue(); } /** @@ -107,7 +106,7 @@ public static function getContentSecurityPolicy(): string * * @return string Returns the custom CSP header value. */ - protected static function getCustomContentSecurityPolicy(): string + protected static function getCustomHeaderValue(): string { $csp = static::getInstance(); @@ -130,11 +129,11 @@ protected static function getCustomContentSecurityPolicy(): string * @return string Returns the generated header value. * @throws RuntimeException If no nonce set for CSS */ - public static function getAutomaticContentSecurityPolicy(): string + public static function getAutomaticHeaderValue(): string { $cspDirectives = []; - $policyDirectives = self::collectContentSecurityPolicyDirectives(); + $policyDirectives = self::collectDirectives(); foreach ($policyDirectives as $directive) { foreach ($directive['directives'] as $directive => $policies) { diff --git a/library/Icinga/Web/Response.php b/library/Icinga/Web/Response.php index 7a9e6033b7..6cb25ae163 100644 --- a/library/Icinga/Web/Response.php +++ b/library/Icinga/Web/Response.php @@ -383,7 +383,7 @@ protected function prepare() $this->setRedirect($redirectUrl->getAbsoluteUrl()); } - if (Csp::getStyleNonce() && Csp::isCspEnabled()) { + if (Csp::getStyleNonce() && Csp::isEnabled()) { Csp::addHeader($this); } } diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index 1696096726..d7a14adcfc 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -26,7 +26,7 @@ protected function assemble(): void static::th($this->translate('Value')), ])); - $policyDirectives = Csp::collectContentSecurityPolicyDirectives(); + $policyDirectives = Csp::collectDirectives(); foreach ($policyDirectives as $directiveGroup) { $reason = $directiveGroup['reason']; From 33863bc7a8f9dced8ec91c9b77a9f5946cbb3e95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 12:31:25 +0200 Subject: [PATCH 36/96] Add notification --- application/controllers/ConfigController.php | 1 + 1 file changed, 1 insertion(+) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 6107b1eade..c048ed23d1 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -123,6 +123,7 @@ public function generalAction() if ($useCsp) { $this->getResponse()->setReloadWindow(true); } + Notification::success($this->translate('Content-Security-Policy updated')); }); $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; From 74b39de552de4d2745d80ec28b5d40dea2422902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 25 Mar 2026 10:26:14 +0100 Subject: [PATCH 37/96] Default use_custom_csp to 0 --- application/controllers/ConfigController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index c048ed23d1..4f945bacb5 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -113,8 +113,8 @@ public function generalAction() $config = Config::app(); $cspForm = new CspConfigForm($config); $cspForm->populate([ - 'use_strict_csp' => Csp::isEnabled(), - 'use_custom_csp' => $config->get('security', 'use_custom_csp'), + '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'), ]); From 356b049476be083350dab6064613d5b0c96c051e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 17 Mar 2026 07:16:47 +0100 Subject: [PATCH 38/96] Remove duplicate default-src directive --- library/Icinga/Util/Csp.php | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 92cc3d662e..7855039010 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -144,16 +144,16 @@ public static function getAutomaticHeaderValue(): string } } - $header = "default-src 'self'; "; + $headerSegments = []; foreach ($cspDirectives as $directive => $policyDirectives) { - if (! empty($policyDirectives)) { - $header .= ' ' . - implode(' ', array_merge([$directive], array_unique($policyDirectives))) - . ';'; + if (empty($policyDirectives)) { + continue; } + + $headerSegments[] = implode(' ', array_merge([$directive], array_unique($policyDirectives))); } - return $header; + return implode('; ', $headerSegments); } /** From d1eb2b674512697202a6ac25fe62d3bd442f2eb8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 17 Mar 2026 07:45:14 +0100 Subject: [PATCH 39/96] Store populated values in hidden form elements --- application/forms/Config/General/CspConfigForm.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index f984987944..2ba477d952 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -43,7 +43,10 @@ protected function assemble(): void ], ); - if ($this->getValue('use_strict_csp') === 'y') { + if ($this->getValue('use_strict_csp') !== 'y') { + $this->addElement('hidden', 'use_custom_csp'); + $this->addElement('hidden', 'custom_csp'); + } else { $this->addElement( 'checkbox', 'use_custom_csp', From fdd7ee4cf0d488beb022d9fb53abd3573dc55e9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 17 Mar 2026 08:39:57 +0100 Subject: [PATCH 40/96] Only store and reload page if necessary --- application/controllers/ConfigController.php | 8 ++-- .../forms/Config/General/CspConfigForm.php | 38 +++++++++++++++---- 2 files changed, 35 insertions(+), 11 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 4f945bacb5..a63deceb9f 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -29,6 +29,7 @@ use Icinga\Web\Notification; use Icinga\Web\Url; use Icinga\Web\Widget; +use ipl\Html\Contract\Form as ContractForm; use ipl\Html\Form; /** @@ -118,9 +119,8 @@ public function generalAction() 'custom_csp' => $config->get('security', 'custom_csp'), ]); - $cspForm->on(Form::ON_SUBMIT, function (Form $form) use ($config) { - $useCsp = $form->getValue('use_strict_csp') === 'y'; - if ($useCsp) { + $cspForm->on(ContractForm::ON_SUBMIT, function (CspConfigForm $form) use ($config) { + if ($form->isCspEnabled() && $form->hasConfigChanged()) { $this->getResponse()->setReloadWindow(true); } Notification::success($this->translate('Content-Security-Policy updated')); @@ -128,7 +128,7 @@ public function generalAction() $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; - if ($cspForm->getValue('use_strict_csp') === 'y') { + if ($cspForm->isCspEnabled()) { $this->view->cspTable = (new CspConfigurationTable())->render(); } else { $this->view->cspTable = ''; diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 2ba477d952..524f2da4f7 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -18,6 +18,8 @@ class CspConfigForm extends CompatForm use FormUid; use CsrfCounterMeasure; + protected bool $changed = false; + public function __construct(protected Config $config) { $this->setAttribute("name", "csp_config"); @@ -43,7 +45,7 @@ protected function assemble(): void ], ); - if ($this->getValue('use_strict_csp') !== 'y') { + if (! $this->isCspEnabled()) { $this->addElement('hidden', 'use_custom_csp'); $this->addElement('hidden', 'custom_csp'); } else { @@ -59,7 +61,7 @@ protected function assemble(): void ], ); - if ($this->getValue('use_custom_csp') === 'y') { + if ($this->isCustomCspEnabled()) { $this->addHtml((new Callout( CalloutType::Warning, $this->translate( @@ -103,17 +105,39 @@ protected function onSuccess(): void $config = Config::app(); $section = $config->getSection('security'); + $beforeSection = clone $section; $section['use_strict_csp'] = $this->getValue('use_strict_csp'); - $useCsp = $this->getPopulatedValue('use_strict_csp', 'n') === 'y'; - if ($useCsp) { + if ($this->isCspEnabled()) { $section['use_custom_csp'] = $this->getValue('use_custom_csp'); - $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; - if ($useCustomCsp) { + if ($this->isCustomCspEnabled()) { $section['custom_csp'] = $this->getValue('custom_csp'); } } - $config->setSection('security', $section); + + $this->changed = ! empty(array_diff_assoc( + iterator_to_array($section), + iterator_to_array($beforeSection) + )); + + if (! $this->changed) { + return; + } $config->saveIni(); } + + public function hasConfigChanged(): bool + { + return $this->changed; + } + + public function isCspEnabled(): bool + { + return $this->getValue('use_strict_csp') === 'y'; + } + + public function isCustomCspEnabled(): bool + { + return $this->getValue('use_custom_csp') === 'y'; + } } From 862f3bee895246b54125ca73e7f9d85fac754df6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 17 Mar 2026 09:37:57 +0100 Subject: [PATCH 41/96] Navigation items that have children can also link to something --- library/Icinga/Util/Csp.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 7855039010..623334d0b6 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -342,14 +342,14 @@ protected static function yieldNavigation(NavigationItem $item): Generator foreach ($item as $child) { yield from self::yieldNavigation($child); } - } else { - $url = $item->getUrl(); - if ($url === null) { - return; - } - if ($item->getTarget() !== '_blank' && $url->isExternal()) { - yield $item->getName() => $item->getUrl(); - } + } + + $url = $item->getUrl(); + if ($url === null) { + return; + } + if ($item->getTarget() !== '_blank' && $url->isExternal()) { + yield $item->getName() => $item->getUrl(); } } From 9deb914736bc7291e99343575a9d4506c633b41b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 17 Mar 2026 10:01:35 +0100 Subject: [PATCH 42/96] Include the port in the navigation URL --- library/Icinga/Util/Csp.php | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 623334d0b6..dad7f7384f 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -15,6 +15,7 @@ use Icinga\Web\Navigation\Navigation; use Icinga\Web\Navigation\NavigationItem; use Icinga\Web\Response; +use Icinga\Web\Url; use Icinga\Web\Widget\Dashboard; use Icinga\Web\Window; use RuntimeException; @@ -314,9 +315,13 @@ protected static function yieldNavigationOrigins(): Generator $navigation = new Navigation(); foreach ($navigation->load($type) as $navItem) { foreach (self::yieldNavigation($navItem) as $name => $url) { + $cspUrl = $url->getScheme() . '://' . $url->getHost(); + if (($port = $url->getPort()) !== null) { + $cspUrl .= ':' . $port; + } yield [ 'directives' => [ - 'frame-src' => [$url->getScheme() . '://' . $url->getHost()], + 'frame-src' => [$cspUrl], ], 'reason' => [ 'type' => 'navigation', @@ -385,9 +390,16 @@ protected static function yieldDashletOrigins(): Generator continue; } + $absoluteUrl = Url::fromPath($absoluteUrl); + + $cspUrl = $absoluteUrl->getScheme() . '://' . $absoluteUrl->getHost(); + if (($port = $absoluteUrl->getPort()) !== null) { + $cspUrl .= ':' . $port; + } + yield [ 'directives' => [ - 'frame-src' => [$absoluteUrl], + 'frame-src' => [$cspUrl], ], 'reason' => [ 'type' => 'dashlet', From 14524c833bd4b551239c3a3370721e45d2492de6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 17 Mar 2026 10:02:04 +0100 Subject: [PATCH 43/96] Navigation items on the top level should not have themselves as a parent --- library/Icinga/Util/Csp.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index dad7f7384f..649715f21b 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -326,7 +326,7 @@ protected static function yieldNavigationOrigins(): Generator 'reason' => [ 'type' => 'navigation', 'name' => $name, - 'parent' => $navItem->getName(), + 'parent' => $name !== $navItem->getName() ? $navItem->getName() : null, 'navType' => $type, ] ]; From 9417b203a2935252d5dba0b6184abf3bff52cfb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 17 Mar 2026 09:39:55 +0100 Subject: [PATCH 44/96] Use 0/1 instead of n/y for config values This makes it compatible with previous versions. --- .../forms/Config/General/CspConfigForm.php | 20 +++++++++++-------- library/Icinga/Util/Csp.php | 6 ++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 524f2da4f7..6402f7dff7 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -36,12 +36,14 @@ protected function assemble(): void 'checkbox', 'use_strict_csp', [ - 'label' => $this->translate('Enable strict CSP'), - 'description' => $this->translate( + 'label' => $this->translate('Enable strict CSP'), + 'description' => $this->translate( 'Set whether to use strict content security policy (CSP).' . ' This setting helps to protect from cross-site scripting (XSS).', ), - 'class' => 'autosubmit', + 'class' => 'autosubmit', + 'checkedValue' => '1', + 'uncheckedValue' => '0', ], ); @@ -53,11 +55,13 @@ protected function assemble(): void 'checkbox', 'use_custom_csp', [ - 'label' => $this->translate('Enable Custom CSP'), - 'description' => $this->translate( + 'label' => $this->translate('Enable Custom CSP'), + 'description' => $this->translate( 'Specify whether to use a custom, user provided, string as the CSP-Header.', ), - 'class' => 'autosubmit', + 'class' => 'autosubmit', + 'checkedValue' => '1', + 'uncheckedValue' => '0', ], ); @@ -133,11 +137,11 @@ public function hasConfigChanged(): bool public function isCspEnabled(): bool { - return $this->getValue('use_strict_csp') === 'y'; + return $this->getValue('use_strict_csp') === '1'; } public function isCustomCspEnabled(): bool { - return $this->getValue('use_custom_csp') === 'y'; + return $this->getValue('use_custom_csp') === '1'; } } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 649715f21b..22f9b324d1 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -63,9 +63,7 @@ public static function addHeader(Response $response): void public static function isEnabled(): bool { - $value = Config::app()->get('security', 'use_strict_csp', 'n'); - - return in_array($value, ['y', '1']); + return Config::app()->get('security', 'use_strict_csp', '0') === '1'; } /** @@ -94,7 +92,7 @@ public static function collectDirectives(): array public static function getHeader(): string { $config = Config::app(); - if ($config->get('security', 'use_custom_csp', 'y') === 'y') { + if ($config->get('security', 'use_custom_csp', '0') === '1') { return self::getCustomHeaderValue(); } From c7bc5b8d394072fd79ad253094690a1b89113f79 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 17 Mar 2026 10:45:42 +0100 Subject: [PATCH 45/96] Removed unnecessary call to getUsername --- library/Icinga/Util/Csp.php | 1 - 1 file changed, 1 deletion(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 22f9b324d1..674efaae85 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -401,7 +401,6 @@ protected static function yieldDashletOrigins(): Generator ], 'reason' => [ 'type' => 'dashlet', - 'user' => $user->getUsername(), 'pane' => $pane->getName(), 'dashlet' => $dashlet->getName(), ] From b890ec3952170fc5c1c4a73df63c3ec9983a3cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 17 Mar 2026 11:30:03 +0100 Subject: [PATCH 46/96] Use generator to return the collection of CSP-Directives --- library/Icinga/Util/Csp.php | 23 ++++++++----------- .../Web/Widget/CspConfigurationTable.php | 8 +++---- 2 files changed, 12 insertions(+), 19 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 674efaae85..500813a04c 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -68,19 +68,15 @@ public static function isEnabled(): bool /** * Collects all CSP directives in an array where the system defaults are first. - * This is done over using a Generator because the order of the directives is important. * - * @return array the list of CSP directives + * @return Generator the list of CSP directives */ - public static function collectDirectives(): array + public static function collectDirectives(): Generator { - // Create an array here because system origins should always come first. - return array_merge( - iterator_to_array(self::yieldSystemOrigins()), - iterator_to_array(self::yieldNavigationOrigins()), - iterator_to_array(self::yieldDashletOrigins()), - iterator_to_array(self::yieldModuleOrigins()), - ); + yield from self::yieldSystemOrigins(); + yield from self::yieldNavigationOrigins(); + yield from self::yieldDashletOrigins(); + yield from self::yieldModuleOrigins(); } /** @@ -131,10 +127,7 @@ protected static function getCustomHeaderValue(): string public static function getAutomaticHeaderValue(): string { $cspDirectives = []; - - $policyDirectives = self::collectDirectives(); - - foreach ($policyDirectives as $directive) { + foreach (self::collectDirectives() as $directive) { foreach ($directive['directives'] as $directive => $policies) { if (! isset($cspDirectives[$directive])) { $cspDirectives[$directive] = []; @@ -143,6 +136,8 @@ public static function getAutomaticHeaderValue(): string } } + unset($policyDirectives); + $headerSegments = []; foreach ($cspDirectives as $directive => $policyDirectives) { if (empty($policyDirectives)) { diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index d7a14adcfc..a32441729e 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -26,10 +26,8 @@ protected function assemble(): void static::th($this->translate('Value')), ])); - $policyDirectives = Csp::collectDirectives(); - - foreach ($policyDirectives as $directiveGroup) { - $reason = $directiveGroup['reason']; + foreach (Csp::collectDirectives() as $directive) { + $reason = $directive['reason']; $type = $reason['type']; $info = match ($type) { 'navigation' => $reason['navType'] @@ -39,7 +37,7 @@ protected function assemble(): void 'module' => $reason['module'], default => '-', }; - foreach ($directiveGroup['directives'] as $directive => $policies) { + foreach ($directive['directives'] as $directive => $policies) { $this->add(static::tr([ static::td($type), static::td($info), From 461a78261a29c45861d4cc3a986c6a6befb9c90e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 18 Mar 2026 13:54:55 +0100 Subject: [PATCH 47/96] Split CSP-Table into multiple with apropriate headers. This commit also adds parsing to the policies. Turning urls into clickable links, and coloring potentially dangerous policies orange. --- .../Web/Widget/CspConfigurationTable.php | 222 ++++++++++++++++-- 1 file changed, 196 insertions(+), 26 deletions(-) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index a32441729e..46e05b2792 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -5,46 +5,216 @@ namespace Icinga\Web\Widget; use Icinga\Util\Csp; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; use ipl\Html\Table; use ipl\I18n\Translation; -class CspConfigurationTable extends Table +class CspConfigurationTable extends BaseHtmlElement { use Translation; + protected $tag = 'div'; + public function __construct() { $this->getAttributes()->add('class', 'csp-config-table'); } + protected function buildTable( + string $filterType, + array $csp, + array $header, + callable $rowBuilder + ): Table { + $table = new Table(); + $headerRow = Table::tr(); + foreach ($header as $h) { + $headerRow->add(Table::th($h)); + } + $table->add($headerRow); + foreach ($csp as $row) { + $reason = $row['reason']; + $type = $reason['type']; + if ($type !== $filterType) { + continue; + } + foreach ($row['directives'] as $directive => $policies) { + if (count($policies) === 0) { + continue; + } + foreach ($policies as $k => $policy) { + $table->add($rowBuilder($reason, $directive, $policy)); + } + } + } + return $table; + } + protected function assemble(): void { - $this->add(static::tr([ - static::th($this->translate('Type')), - static::th($this->translate('Info')), - static::th($this->translate('Directive')), - static::th($this->translate('Value')), - ])); - - foreach (Csp::collectDirectives() as $directive) { - $reason = $directive['reason']; - $type = $reason['type']; - $info = match ($type) { - 'navigation' => $reason['navType'] - . '/' . ($reason['parent'] !== null ? ($reason['parent'] . '/') : '') - . $reason['name'], - 'dashlet' => $reason['pane'] . '/' . $reason['dashlet'], - 'module' => $reason['module'], - default => '-', - }; - foreach ($directive['directives'] as $directive => $policies) { - $this->add(static::tr([ - static::td($type), - static::td($info), - static::td($directive), - static::td(join(', ', $policies)), - ])); + $csp = iterator_to_array(Csp::collectDirectives(), false); + + $this->add(HtmlElement::create('h3', null, $this->translate('System'))); + $this->add($this->buildTable( + 'system', + $csp, + [t('Directive'), t('Value')], + function (array $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($directive), + $this->buildPolicy($policy), + ]); + }, + )); + + $this->add(HtmlElement::create('h3', null, $this->translate('Dashboards'))); + $this->add($this->buildTable( + 'dashlet', + $csp, + [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], + function (array $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($reason['pane']), + Table::td($reason['dashlet']), + Table::td($directive), + $this->buildPolicy($policy), + ]); } + )); + + // TODO: Handle other types of navigation in extra tables + $this->add(HtmlElement::create('h3', null, $this->translate('Navigation'))); + $this->add($this->buildTable( + 'navigation', + $csp, + [t('Type'), t('Name'), t('Parent'), t('Directive'), t('Value')], + function (array $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($reason['navType']), + Table::td($reason['name']), + Table::td($reason['parent'] ?? 'NA'), + Table::td($directive), + $this->buildPolicy($policy), + ]); + } + )); + + $this->add(HtmlElement::create('h3', null, $this->translate('Modules'))); + $this->add($this->buildTable( + 'module', + $csp, + [t('Module'), t('Directive'), t('Value')], + function (array $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($reason['module']), + Table::td($directive), + $this->buildPolicy($policy), + ]); + } + )); + } + + protected function getKeywordType(string $policy): ?string + { + $secureKeywords = [ + "'self'", + "'none'", + "'strict-dynamic'", + "'report-sample'", + "'report-sha256'", + "'report-sha384'", + "'report-sha512'", + ]; + + if (in_array($policy, $secureKeywords)) { + return 'secure'; + } + + $warningKeywords = [ + "'unsafe-inline'", + "'unsafe-eval'", + "'unsafe-hashes'", + ]; + + if (in_array($policy, $warningKeywords)) { + return 'warning'; + } + + return null; + } + + protected function getSchemeType(string $policy): ?string + { + if (! str_ends_with($policy, ':')) { + return null; + } + + if (str_contains($policy, ' ')) { + return null; + } + + $scheme = substr($policy, 0, -1); + + $secureSchemes = [ + 'https', + 'wss', + ]; + + if (in_array($scheme, $secureSchemes)) { + return 'secure'; + } + + $warningSchemes = [ + 'http', + 'ws', + 'blob', + 'data', + ]; + + if (in_array($scheme, $warningSchemes)) { + return 'warning'; + } + + return 'unknown'; + } + + protected function isNonce(string $policy): bool + { + return (str_starts_with($policy, "'nonce-") && str_ends_with($policy, "'")); + } + + protected function buildPolicy(string $policy): BaseHtmlElement + { + if ($policy === '*') { + $result = HtmlElement::create('span', ['class' => 'wildcard'], $policy); + } else if ($policy === "'self'") { + $result = HtmlElement::create('span', ['class' => 'self'], $policy); + } else if (($keyword = $this->getKeywordType($policy)) !== null) { + $result = HtmlElement::create( + 'span', ['class' => ['keyword', $keyword]], $policy + ); + } else if (($scheme = $this->getSchemeType($policy)) !== null) { + $result = HtmlElement::create( + 'span', ['class' => ['scheme', $scheme]], $policy + ); + } else if ($this->isNonce($policy)) { + $result = HtmlElement::create( + 'span', ['class' => 'nonce'], $policy + ); + } else if (filter_var($policy, FILTER_VALIDATE_URL) !== false) { + $result = HtmlElement::create( + 'a', + [ + 'href' => $policy, + 'class' => 'url', + 'target' => '_blank', + ], + $policy, + ); + } else { + $result = HtmlElement::create('span', null, $policy); } + return Table::td($result, ['class' => 'csp-policies']); } } From 54db0b5da5751e4ec2a99f0a307f2f4796640310 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 18 Mar 2026 14:05:01 +0100 Subject: [PATCH 48/96] Hide tables with no content --- .../Web/Widget/CspConfigurationTable.php | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index 46e05b2792..564422b4e5 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -21,18 +21,14 @@ public function __construct() $this->getAttributes()->add('class', 'csp-config-table'); } - protected function buildTable( + protected function addPolicyTable( + string $title, string $filterType, array $csp, array $header, callable $rowBuilder - ): Table { - $table = new Table(); - $headerRow = Table::tr(); - foreach ($header as $h) { - $headerRow->add(Table::th($h)); - } - $table->add($headerRow); + ): void { + $rows = []; foreach ($csp as $row) { $reason = $row['reason']; $type = $reason['type']; @@ -44,19 +40,37 @@ protected function buildTable( continue; } foreach ($policies as $k => $policy) { - $table->add($rowBuilder($reason, $directive, $policy)); + $rows[] = $rowBuilder($reason, $directive, $policy); } } } - return $table; + + if (count($rows) === 0) { + return; + } + + $this->add(HtmlElement::create('h3', null, $title)); + + $table = new Table(); + $headerRow = Table::tr(); + foreach ($header as $h) { + $headerRow->add(Table::th($h)); + } + $table->add($headerRow); + + foreach ($rows as $row) { + $table->add($row); + } + + $this->add($table); } protected function assemble(): void { $csp = iterator_to_array(Csp::collectDirectives(), false); - $this->add(HtmlElement::create('h3', null, $this->translate('System'))); - $this->add($this->buildTable( + $this->addPolicyTable( + t('System'), 'system', $csp, [t('Directive'), t('Value')], @@ -66,10 +80,10 @@ function (array $reason, string $directive, string $policy) { $this->buildPolicy($policy), ]); }, - )); + ); - $this->add(HtmlElement::create('h3', null, $this->translate('Dashboards'))); - $this->add($this->buildTable( + $this->addPolicyTable( + t('Dashboard'), 'dashlet', $csp, [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], @@ -81,11 +95,11 @@ function (array $reason, string $directive, string $policy) { $this->buildPolicy($policy), ]); } - )); + ); // TODO: Handle other types of navigation in extra tables - $this->add(HtmlElement::create('h3', null, $this->translate('Navigation'))); - $this->add($this->buildTable( + $this->addPolicyTable( + t('Navigation'), 'navigation', $csp, [t('Type'), t('Name'), t('Parent'), t('Directive'), t('Value')], @@ -98,10 +112,10 @@ function (array $reason, string $directive, string $policy) { $this->buildPolicy($policy), ]); } - )); + ); - $this->add(HtmlElement::create('h3', null, $this->translate('Modules'))); - $this->add($this->buildTable( + $this->addPolicyTable( + t('Modules'), 'module', $csp, [t('Module'), t('Directive'), t('Value')], @@ -112,7 +126,7 @@ function (array $reason, string $directive, string $policy) { $this->buildPolicy($policy), ]); } - )); + ); } protected function getKeywordType(string $policy): ?string From 3c1a2023c933c58f999a86ee2be2e3b5cd6266e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 18 Mar 2026 15:18:21 +0100 Subject: [PATCH 49/96] Use Link widget --- library/Icinga/Web/Widget/CspConfigurationTable.php | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index 564422b4e5..83c23079cb 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -9,6 +9,7 @@ use ipl\Html\HtmlElement; use ipl\Html\Table; use ipl\I18n\Translation; +use ipl\Web\Widget\Link; class CspConfigurationTable extends BaseHtmlElement { @@ -217,15 +218,7 @@ protected function buildPolicy(string $policy): BaseHtmlElement 'span', ['class' => 'nonce'], $policy ); } else if (filter_var($policy, FILTER_VALIDATE_URL) !== false) { - $result = HtmlElement::create( - 'a', - [ - 'href' => $policy, - 'class' => 'url', - 'target' => '_blank', - ], - $policy, - ); + $result = new Link($policy, $policy, ['target' => '_blank']); } else { $result = HtmlElement::create('span', null, $policy); } From 3990c0d312d9379f529f25ea0142f3dadd8510c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 18 Mar 2026 15:18:45 +0100 Subject: [PATCH 50/96] Move table into form --- application/controllers/ConfigController.php | 6 -- .../forms/Config/General/CspConfigForm.php | 13 +++ .../views/scripts/config/general.phtml | 1 - library/Icinga/Web/StyleSheet.php | 1 + public/css/icinga/csp-config-editor.less | 97 +++++++++++++++++++ public/css/icinga/widgets.less | 5 - 6 files changed, 111 insertions(+), 12 deletions(-) create mode 100644 public/css/icinga/csp-config-editor.less diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index a63deceb9f..cc594ba8f0 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -128,12 +128,6 @@ public function generalAction() $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; - if ($cspForm->isCspEnabled()) { - $this->view->cspTable = (new CspConfigurationTable())->render(); - } else { - $this->view->cspTable = ''; - } - $this->createApplicationTabs()->activate('general'); } diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 6402f7dff7..d161e5dd59 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -7,6 +7,8 @@ use Icinga\Application\Config; use Icinga\Util\Csp; use Icinga\Web\Session; +use Icinga\Web\Widget\CspConfigurationTable; +use ipl\Html\HtmlElement; use ipl\Web\Common\CalloutType; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Common\FormUid; @@ -23,6 +25,7 @@ class CspConfigForm extends CompatForm public function __construct(protected Config $config) { $this->setAttribute("name", "csp_config"); + $this->getAttributes()->add("class", "csp-config-form"); $this->applyDefaultElementDecorators(); } @@ -96,6 +99,16 @@ protected function assemble(): void 'disabled' => true, 'value' => Csp::getAutomaticHeaderValue(), ]); + + + $this->add(HtmlElement::create( + 'div', + [ + 'class' => 'collapsible', + 'data-visible-height' => 250, + ], + new CspConfigurationTable(), + )); } } diff --git a/application/views/scripts/config/general.phtml b/application/views/scripts/config/general.phtml index ecb387c8d5..a5ab32b786 100644 --- a/application/views/scripts/config/general.phtml +++ b/application/views/scripts/config/general.phtml @@ -7,5 +7,4 @@

translate('Content Security Policy') ?>

- diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index fc7a025f12..e2039b08a4 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -74,6 +74,7 @@ class StyleSheet 'css/icinga/login.less', 'css/icinga/about.less', 'css/icinga/controls.less', + 'css/icinga/csp-config-editor.less', 'css/icinga/dev.less', 'css/icinga/spinner.less', 'css/icinga/compat.less', diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less new file mode 100644 index 0000000000..b83219b413 --- /dev/null +++ b/public/css/icinga/csp-config-editor.less @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2026 Icinga GmbH +// SPDX-License-Identifier: GPL-3.0-or-later + +// Layout +.csp-config-table { + h3 { + margin-top: 0; + + &:not(:first-child) { + margin-top: 1em; + } + } + + table { + width: 100%; + overflow-x: auto; + display: block; + } + + th:first-child, + td:first-child { + padding-right: 0; + } + + th:last-child, + td:last-child { + width: 100%; + } + + .csp-policies { + display: flex; + flex-direction: row; + justify-content: end; + gap: 0.25em; + } +} + +// 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; + } + + .self { + opacity: 0.5; + } + + .warning, + .wildcard { + color: @color-warning; + } + + .secure { + color: @color-ok; + } + + .nonce { + color: @color-unknown; + } + + a { + font-weight: bold; + + &:hover { + color: @icinga-blue; + text-decoration: none; + } + } +} + +// Form layout +.csp-config-form { + .csp-config-table { + margin-left: 14em; + } + + .btn-primary { + margin-top: 1em; + } +} diff --git a/public/css/icinga/widgets.less b/public/css/icinga/widgets.less index fa3dcf0c68..c482f0ce5e 100644 --- a/public/css/icinga/widgets.less +++ b/public/css/icinga/widgets.less @@ -665,8 +665,3 @@ ul.tree li a.error:hover { html.no-js .progress-label { display: none; } - -.csp-config-table { - width: 80%; - max-width: 70em; -} From 7d3704974c10f294f4bc46f3668cb216f0a57a50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 18 Mar 2026 16:02:57 +0100 Subject: [PATCH 51/96] Change naming of button to "Send CSP-Header" --- application/forms/Config/General/CspConfigForm.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index d161e5dd59..9224a641c4 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -39,9 +39,9 @@ protected function assemble(): void 'checkbox', 'use_strict_csp', [ - 'label' => $this->translate('Enable strict CSP'), + 'label' => $this->translate('Send CSP-Header'), 'description' => $this->translate( - 'Set whether to use strict content security policy (CSP).' + 'Use strict content security policy (CSP).' . ' This setting helps to protect from cross-site scripting (XSS).', ), 'class' => 'autosubmit', From 45693c371b8c7a38377909dab999071e8b0060c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 19 Mar 2026 07:45:39 +0100 Subject: [PATCH 52/96] Color the "data:" schema based on the directive --- .../Web/Widget/CspConfigurationTable.php | 45 +++++++++++++------ public/css/icinga/csp-config-editor.less | 8 +++- 2 files changed, 38 insertions(+), 15 deletions(-) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index 83c23079cb..dbdf19b780 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -78,7 +78,7 @@ protected function assemble(): void function (array $reason, string $directive, string $policy) { return Table::tr([ Table::td($directive), - $this->buildPolicy($policy), + $this->buildPolicy($directive, $policy), ]); }, ); @@ -93,7 +93,7 @@ function (array $reason, string $directive, string $policy) { Table::td($reason['pane']), Table::td($reason['dashlet']), Table::td($directive), - $this->buildPolicy($policy), + $this->buildPolicy($directive, $policy), ]); } ); @@ -110,7 +110,7 @@ function (array $reason, string $directive, string $policy) { Table::td($reason['name']), Table::td($reason['parent'] ?? 'NA'), Table::td($directive), - $this->buildPolicy($policy), + $this->buildPolicy($directive, $policy), ]); } ); @@ -124,7 +124,7 @@ function (array $reason, string $directive, string $policy) { return Table::tr([ Table::td($reason['module']), Table::td($directive), - $this->buildPolicy($policy), + $this->buildPolicy($directive, $policy), ]); } ); @@ -159,7 +159,7 @@ protected function getKeywordType(string $policy): ?string return null; } - protected function getSchemeType(string $policy): ?string + protected function getSchemeType(string $directive, string $policy): ?string { if (! str_ends_with($policy, ':')) { return null; @@ -169,25 +169,44 @@ protected function getSchemeType(string $policy): ?string return null; } - $scheme = substr($policy, 0, -1); + $schema = substr($policy, 0, -1); - $secureSchemes = [ + $secureSchemas = [ 'https', 'wss', ]; - if (in_array($scheme, $secureSchemes)) { + if (in_array($schema, $secureSchemas)) { return 'secure'; } - $warningSchemes = [ + $warningSchemas = [ 'http', 'ws', 'blob', - 'data', ]; - if (in_array($scheme, $warningSchemes)) { + if (in_array($schema, $warningSchemas)) { + return 'warning'; + } + + if ($schema === 'data' && in_array($directive, + [ + 'default-src', + 'script-src', + 'object-src', + 'frame-src', + ])) { + return 'critical'; + } + + if ($schema === 'data' && in_array($directive, + [ + 'style-src', + 'worker-src', + 'child-src', + 'base-uri', + ])) { return 'warning'; } @@ -199,7 +218,7 @@ protected function isNonce(string $policy): bool return (str_starts_with($policy, "'nonce-") && str_ends_with($policy, "'")); } - protected function buildPolicy(string $policy): BaseHtmlElement + protected function buildPolicy(string $directive, string $policy): BaseHtmlElement { if ($policy === '*') { $result = HtmlElement::create('span', ['class' => 'wildcard'], $policy); @@ -209,7 +228,7 @@ protected function buildPolicy(string $policy): BaseHtmlElement $result = HtmlElement::create( 'span', ['class' => ['keyword', $keyword]], $policy ); - } else if (($scheme = $this->getSchemeType($policy)) !== null) { + } else if (($scheme = $this->getSchemeType($directive, $policy)) !== null) { $result = HtmlElement::create( 'span', ['class' => ['scheme', $scheme]], $policy ); diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less index b83219b413..86635f7667 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -62,11 +62,15 @@ opacity: 0.5; } - .warning, - .wildcard { + .warning{ color: @color-warning; } + .wildcard, + .critical { + color: @color-critical; + } + .secure { color: @color-ok; } From 00d511c2727c659b638ce4691ccda681b29a93b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 19 Mar 2026 09:57:24 +0100 Subject: [PATCH 53/96] Code style & Move arrays to class constants --- .../Web/Widget/CspConfigurationTable.php | 120 +++++++++--------- 1 file changed, 61 insertions(+), 59 deletions(-) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index dbdf19b780..897e0d8da3 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -15,6 +15,53 @@ class CspConfigurationTable extends BaseHtmlElement { use Translation; + /** @var string[] */ + protected const SECURE_KEYWORDS = [ + "'self'", + "'none'", + "'strict-dynamic'", + "'report-sample'", + "'report-sha256'", + "'report-sha384'", + "'report-sha512'", + ]; + + /** @var string[] */ + protected const WARNING_KEYWORDS = [ + "'unsafe-inline'", + "'unsafe-eval'", + "'unsafe-hashes'", + ]; + + /** @var string[] */ + protected const SECURE_SCHEMAS = [ + 'https', + 'wss', + ]; + + /** @var string[] */ + protected const WARNING_SCHEMAS = [ + 'http', + 'ws', + 'blob', + ]; + + /** @var string[] */ + protected const CRITICAL_DATA_DIRECTIVES = [ + 'default-src', + 'script-src', + 'object-src', + 'frame-src', + ]; + + /** @var string[] */ + protected const WARNING_DATA_DIRECTIVES = [ + 'style-src', + 'worker-src', + 'child-src', + 'base-uri', + ]; + protected $tag = 'div'; public function __construct() @@ -132,27 +179,11 @@ function (array $reason, string $directive, string $policy) { protected function getKeywordType(string $policy): ?string { - $secureKeywords = [ - "'self'", - "'none'", - "'strict-dynamic'", - "'report-sample'", - "'report-sha256'", - "'report-sha384'", - "'report-sha512'", - ]; - - if (in_array($policy, $secureKeywords)) { + if (in_array($policy, static::SECURE_KEYWORDS)) { return 'secure'; } - $warningKeywords = [ - "'unsafe-inline'", - "'unsafe-eval'", - "'unsafe-hashes'", - ]; - - if (in_array($policy, $warningKeywords)) { + if (in_array($policy, static::WARNING_KEYWORDS)) { return 'warning'; } @@ -171,42 +202,19 @@ protected function getSchemeType(string $directive, string $policy): ?string $schema = substr($policy, 0, -1); - $secureSchemas = [ - 'https', - 'wss', - ]; - - if (in_array($schema, $secureSchemas)) { + if (in_array($schema, static::SECURE_SCHEMAS)) { return 'secure'; } - $warningSchemas = [ - 'http', - 'ws', - 'blob', - ]; - - if (in_array($schema, $warningSchemas)) { + if (in_array($schema, static::WARNING_SCHEMAS)) { return 'warning'; } - if ($schema === 'data' && in_array($directive, - [ - 'default-src', - 'script-src', - 'object-src', - 'frame-src', - ])) { + if ($schema === 'data' && in_array($directive, static::CRITICAL_DATA_DIRECTIVES)) { return 'critical'; } - if ($schema === 'data' && in_array($directive, - [ - 'style-src', - 'worker-src', - 'child-src', - 'base-uri', - ])) { + if ($schema === 'data' && in_array($directive, static::WARNING_DATA_DIRECTIVES)) { return 'warning'; } @@ -222,21 +230,15 @@ protected function buildPolicy(string $directive, string $policy): BaseHtmlEleme { if ($policy === '*') { $result = HtmlElement::create('span', ['class' => 'wildcard'], $policy); - } else if ($policy === "'self'") { + } elseif ($policy === "'self'") { $result = HtmlElement::create('span', ['class' => 'self'], $policy); - } else if (($keyword = $this->getKeywordType($policy)) !== null) { - $result = HtmlElement::create( - 'span', ['class' => ['keyword', $keyword]], $policy - ); - } else if (($scheme = $this->getSchemeType($directive, $policy)) !== null) { - $result = HtmlElement::create( - 'span', ['class' => ['scheme', $scheme]], $policy - ); - } else if ($this->isNonce($policy)) { - $result = HtmlElement::create( - 'span', ['class' => 'nonce'], $policy - ); - } else if (filter_var($policy, FILTER_VALIDATE_URL) !== false) { + } elseif (($keyword = $this->getKeywordType($policy)) !== null) { + $result = HtmlElement::create('span', ['class' => ['keyword', $keyword]], $policy); + } elseif (($scheme = $this->getSchemeType($directive, $policy)) !== null) { + $result = HtmlElement::create('span', ['class' => ['scheme', $scheme]], $policy); + } elseif ($this->isNonce($policy)) { + $result = HtmlElement::create('span', ['class' => 'nonce'], $policy); + } elseif (filter_var($policy, FILTER_VALIDATE_URL) !== false) { $result = new Link($policy, $policy, ['target' => '_blank']); } else { $result = HtmlElement::create('span', null, $policy); From f418ad5126089ca30aee9dde1ff618eaff046ff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 20 Mar 2026 07:55:49 +0100 Subject: [PATCH 54/96] Code review changes - Reload of form change if Csp was previously enabled in `ConfigController` - Use default attributes in `CspConfigurationTable` - Rename `$policyDirectives` to `$directivePolicies` in `Csp` --- application/controllers/ConfigController.php | 5 +++-- library/Icinga/Util/Csp.php | 8 +++----- library/Icinga/Web/Widget/CspConfigurationTable.php | 9 +++------ 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index cc594ba8f0..2cea492170 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -119,8 +119,9 @@ public function generalAction() 'custom_csp' => $config->get('security', 'custom_csp'), ]); - $cspForm->on(ContractForm::ON_SUBMIT, function (CspConfigForm $form) use ($config) { - if ($form->isCspEnabled() && $form->hasConfigChanged()) { + $wasCspEnabled = Csp::isEnabled(); + $cspForm->on(ContractForm::ON_SUBMIT, function (CspConfigForm $form) use ($config, $wasCspEnabled) { + if ($form->hasConfigChanged() && ($form->isCspEnabled() || $wasCspEnabled)) { $this->getResponse()->setReloadWindow(true); } Notification::success($this->translate('Content-Security-Policy updated')); diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 500813a04c..c6d1a7549e 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -136,15 +136,13 @@ public static function getAutomaticHeaderValue(): string } } - unset($policyDirectives); - $headerSegments = []; - foreach ($cspDirectives as $directive => $policyDirectives) { - if (empty($policyDirectives)) { + foreach ($cspDirectives as $directive => $directivePolicies) { + if (empty($directivePolicies)) { continue; } - $headerSegments[] = implode(' ', array_merge([$directive], array_unique($policyDirectives))); + $headerSegments[] = implode(' ', array_merge([$directive], array_unique($directivePolicies))); } return implode('; ', $headerSegments); diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index 897e0d8da3..ef5d5f2766 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -15,6 +15,8 @@ class CspConfigurationTable extends BaseHtmlElement { use Translation; + protected $defaultAttributes = ['class' => 'csp-config-table']; + /** @var string[] */ protected const SECURE_KEYWORDS = [ "'self'", @@ -64,11 +66,6 @@ class CspConfigurationTable extends BaseHtmlElement protected $tag = 'div'; - public function __construct() - { - $this->getAttributes()->add('class', 'csp-config-table'); - } - protected function addPolicyTable( string $title, string $filterType, @@ -155,7 +152,7 @@ function (array $reason, string $directive, string $policy) { return Table::tr([ Table::td($reason['navType']), Table::td($reason['name']), - Table::td($reason['parent'] ?? 'NA'), + Table::td($reason['parent'] ?? t('NA')), Table::td($directive), $this->buildPolicy($directive, $policy), ]); From 20745252f957abfe7bbe0d674e7a9d1f2eb6cbde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 23 Mar 2026 10:36:53 +0100 Subject: [PATCH 55/96] Prefixed CSS-classes with `csp-` --- library/Icinga/Web/Widget/CspConfigurationTable.php | 10 +++++----- public/css/icinga/csp-config-editor.less | 12 ++++++------ 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index ef5d5f2766..3bb99cc306 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -226,15 +226,15 @@ protected function isNonce(string $policy): bool protected function buildPolicy(string $directive, string $policy): BaseHtmlElement { if ($policy === '*') { - $result = HtmlElement::create('span', ['class' => 'wildcard'], $policy); + $result = HtmlElement::create('span', ['class' => 'csp-wildcard'], $policy); } elseif ($policy === "'self'") { - $result = HtmlElement::create('span', ['class' => 'self'], $policy); + $result = HtmlElement::create('span', ['class' => 'csp-self'], $policy); } elseif (($keyword = $this->getKeywordType($policy)) !== null) { - $result = HtmlElement::create('span', ['class' => ['keyword', $keyword]], $policy); + $result = HtmlElement::create('span', ['class' => ['csp-keyword', 'csp-' . $keyword]], $policy); } elseif (($scheme = $this->getSchemeType($directive, $policy)) !== null) { - $result = HtmlElement::create('span', ['class' => ['scheme', $scheme]], $policy); + $result = HtmlElement::create('span', ['class' => ['csp-scheme', 'csp-' . $scheme]], $policy); } elseif ($this->isNonce($policy)) { - $result = HtmlElement::create('span', ['class' => 'nonce'], $policy); + $result = HtmlElement::create('span', ['class' => 'csp-nonce'], $policy); } elseif (filter_var($policy, FILTER_VALIDATE_URL) !== false) { $result = new Link($policy, $policy, ['target' => '_blank']); } else { diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less index 86635f7667..cadfcc5cd6 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -58,24 +58,24 @@ text-align: right; } - .self { + .csp-self { opacity: 0.5; } - .warning{ + .csp-warning { color: @color-warning; } - .wildcard, - .critical { + .csp-wildcard, + .csp-critical { color: @color-critical; } - .secure { + .csp-secure { color: @color-ok; } - .nonce { + .csp-nonce { color: @color-unknown; } From a5523260570603a8897fa48cfc82a8b1e2e8f6cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 20 Mar 2026 08:38:50 +0100 Subject: [PATCH 56/96] Add a toggle to enable user content --- application/controllers/ConfigController.php | 1 + .../forms/Config/General/CspConfigForm.php | 31 +++++++++++++++++-- library/Icinga/Util/Csp.php | 17 +++++++--- .../Web/Widget/CspConfigurationTable.php | 7 ++++- 4 files changed, 47 insertions(+), 9 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 2cea492170..5ccde53a03 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -117,6 +117,7 @@ public function generalAction() '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'), + 'include_user_content' => $config->get('security', 'include_user_content'), ]); $wasCspEnabled = Csp::isEnabled(); diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 9224a641c4..c77e07cbe9 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -53,6 +53,7 @@ protected function assemble(): void if (! $this->isCspEnabled()) { $this->addElement('hidden', 'use_custom_csp'); $this->addElement('hidden', 'custom_csp'); + $this->addElement('hidden', 'include_user_content'); } else { $this->addElement( 'checkbox', @@ -69,6 +70,8 @@ protected function assemble(): void ); if ($this->isCustomCspEnabled()) { + $this->addElement('hidden', 'include_user_content'); + $this->addHtml((new Callout( CalloutType::Warning, $this->translate( @@ -89,6 +92,22 @@ protected function assemble(): void } else { $this->addElement('hidden', 'custom_csp'); + $this->addElement( + 'checkbox', + 'include_user_content', + [ + 'label' => $this->translate('Include User Content'), + 'description' => $this->translate( + 'If enabled, the user defined content like iframes in dashboards or ' + . 'menus will be included. Note: You will only be able to see the content that you ' + . 'have access to. There is no way to know what others have configured for themselves', + ), + 'class' => 'autosubmit', + 'checkedValue' => '1', + 'uncheckedValue' => '0', + ], + ); + Csp::createNonce(); $this->addElement('textarea', 'generated_csp', [ 'label' => $this->translate('Generated CSP'), @@ -97,17 +116,16 @@ protected function assemble(): void . ' Enable Custom CSP checkbox above.', ), 'disabled' => true, - 'value' => Csp::getAutomaticHeaderValue(), + 'value' => Csp::getAutomaticHeaderValue($this->shouldIncludeUserContent()), ]); - $this->add(HtmlElement::create( 'div', [ 'class' => 'collapsible', 'data-visible-height' => 250, ], - new CspConfigurationTable(), + new CspConfigurationTable($this->shouldIncludeUserContent()), )); } } @@ -128,6 +146,8 @@ protected function onSuccess(): void $section['use_custom_csp'] = $this->getValue('use_custom_csp'); if ($this->isCustomCspEnabled()) { $section['custom_csp'] = $this->getValue('custom_csp'); + } else { + $section['include_user_content'] = $this->getValue('include_user_content'); } } @@ -148,6 +168,11 @@ public function hasConfigChanged(): bool return $this->changed; } + public function shouldIncludeUserContent(): bool + { + return $this->getValue('include_user_content') === '1'; + } + public function isCspEnabled(): bool { return $this->getValue('use_strict_csp') === '1'; diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index c6d1a7549e..c62f68b280 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -69,14 +69,21 @@ public static function isEnabled(): bool /** * Collects all CSP directives in an array where the system defaults are first. * + * @param bool|null $includeUserContent + * * @return Generator the list of CSP directives */ - public static function collectDirectives(): Generator + public static function collectDirectives(?bool $includeUserContent = null): Generator { + if ($includeUserContent === null) { + $includeUserContent = Config::app()->get('security', 'include_user_content', '0') === '1'; + } yield from self::yieldSystemOrigins(); - yield from self::yieldNavigationOrigins(); - yield from self::yieldDashletOrigins(); yield from self::yieldModuleOrigins(); + if ($includeUserContent) { + yield from self::yieldNavigationOrigins(); + yield from self::yieldDashletOrigins(); + } } /** @@ -124,10 +131,10 @@ protected static function getCustomHeaderValue(): string * @return string Returns the generated header value. * @throws RuntimeException If no nonce set for CSS */ - public static function getAutomaticHeaderValue(): string + public static function getAutomaticHeaderValue(?bool $includeUserContent = null): string { $cspDirectives = []; - foreach (self::collectDirectives() as $directive) { + foreach (self::collectDirectives($includeUserContent) as $directive) { foreach ($directive['directives'] as $directive => $policies) { if (! isset($cspDirectives[$directive])) { $cspDirectives[$directive] = []; diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index 3bb99cc306..7eca7f85d1 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -66,6 +66,11 @@ class CspConfigurationTable extends BaseHtmlElement protected $tag = 'div'; + public function __construct( + protected ?bool $includeUserContent = null, + ) { + } + protected function addPolicyTable( string $title, string $filterType, @@ -112,7 +117,7 @@ protected function addPolicyTable( protected function assemble(): void { - $csp = iterator_to_array(Csp::collectDirectives(), false); + $csp = iterator_to_array(Csp::collectDirectives($this->includeUserContent), false); $this->addPolicyTable( t('System'), From dedb1e68f5b88dae7d1f7c9f4481940d45e1482e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 20 Mar 2026 09:07:59 +0100 Subject: [PATCH 57/96] Move CSP-Form into a newly created Security tab. This tab requires the new config/security permission --- application/controllers/ConfigController.php | 28 ++++++++++++++++--- .../{General => Security}/CspConfigForm.php | 2 +- application/forms/Security/RoleForm.php | 3 ++ .../views/scripts/config/general.phtml | 4 --- .../views/scripts/config/security.phtml | 7 +++++ 5 files changed, 35 insertions(+), 9 deletions(-) rename application/forms/Config/{General => Security}/CspConfigForm.php (99%) create mode 100644 application/views/scripts/config/security.phtml diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 5ccde53a03..b363f0d74d 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -8,9 +8,7 @@ use Exception; use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; -use Icinga\Forms\Config\General\CspConfigForm; use Icinga\Util\Csp; -use Icinga\Web\Widget\CspConfigurationTable; use InvalidArgumentException; use Icinga\Application\Config; use Icinga\Application\Icinga; @@ -21,6 +19,7 @@ use Icinga\Forms\ActionForm; use Icinga\Forms\Config\GeneralConfigForm; use Icinga\Forms\Config\ResourceConfigForm; +use Icinga\Forms\Config\Security\CspConfigForm; use Icinga\Forms\Config\UserBackendConfigForm; use Icinga\Forms\Config\UserBackendReorderForm; use Icinga\Forms\ConfirmRemovalForm; @@ -30,7 +29,6 @@ use Icinga\Web\Url; use Icinga\Web\Widget; use ipl\Html\Contract\Form as ContractForm; -use ipl\Html\Form; /** * Application and module configuration @@ -51,6 +49,14 @@ public function createApplicationTabs() 'baseTarget' => '_main' )); } + if ($this->hasPermission('config/security')) { + $tabs->add('security', array( + 'title' => $this->translate('Adjust the security configuration of Icinga Web 2'), + 'label' => $this->translate('Security'), + 'url' => 'config/security', + 'baseTarget' => '_main' + )); + } if ($this->hasPermission('config/resources')) { $tabs->add('resource', array( 'title' => $this->translate('Configure which resources are being utilized by Icinga Web 2'), @@ -111,6 +117,20 @@ public function generalAction() $this->view->form = $form; + $this->createApplicationTabs()->activate('general'); + } + + /** + * Security configuration + * + * @throws SecurityException If the user lacks the permission for configuring the security configuration + */ + public function securityAction() + { + $this->assertPermission('config/security'); + + $this->view->title = $this->translate('General'); + $config = Config::app(); $cspForm = new CspConfigForm($config); $cspForm->populate([ @@ -130,7 +150,7 @@ public function generalAction() $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; - $this->createApplicationTabs()->activate('general'); + $this->createApplicationTabs()->activate('security'); } /** diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php similarity index 99% rename from application/forms/Config/General/CspConfigForm.php rename to application/forms/Config/Security/CspConfigForm.php index c77e07cbe9..38c4baa5df 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -2,7 +2,7 @@ /* Icinga Web 2 | (c) 2026 Icinga GmbH | GPLv2+ */ -namespace Icinga\Forms\Config\General; +namespace Icinga\Forms\Config\Security; use Icinga\Application\Config; use Icinga\Util\Csp; diff --git a/application/forms/Security/RoleForm.php b/application/forms/Security/RoleForm.php index ea64fd0cbc..37ce3e0677 100644 --- a/application/forms/Security/RoleForm.php +++ b/application/forms/Security/RoleForm.php @@ -548,6 +548,9 @@ public static function collectProvidedPrivileges() 'config/general' => [ 'description' => t('Allow to adjust the general configuration') ], + 'config/security' => [ + 'description' => t('Allow to adjust the security configuration') + ], 'config/modules' => [ 'description' => t('Allow to enable/disable and configure modules') ], diff --git a/application/views/scripts/config/general.phtml b/application/views/scripts/config/general.phtml index a5ab32b786..13a8ed9ed1 100644 --- a/application/views/scripts/config/general.phtml +++ b/application/views/scripts/config/general.phtml @@ -2,9 +2,5 @@
-

translate('General') ?>

- -

translate('Content Security Policy') ?>

-
diff --git a/application/views/scripts/config/security.phtml b/application/views/scripts/config/security.phtml new file mode 100644 index 0000000000..24208eaf85 --- /dev/null +++ b/application/views/scripts/config/security.phtml @@ -0,0 +1,7 @@ +
+ +
+
+

translate('Content Security Policy') ?>

+ +
From 1b17cac9b7ae90c490b3b2991b4acca85785244a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 20 Mar 2026 14:02:47 +0100 Subject: [PATCH 58/96] Code review suggestions --- application/controllers/ConfigController.php | 7 +++---- application/forms/Config/Security/CspConfigForm.php | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index b363f0d74d..69f2cd943d 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -125,7 +125,7 @@ public function generalAction() * * @throws SecurityException If the user lacks the permission for configuring the security configuration */ - public function securityAction() + public function securityAction(): void { $this->assertPermission('config/security'); @@ -140,9 +140,8 @@ public function securityAction() 'include_user_content' => $config->get('security', 'include_user_content'), ]); - $wasCspEnabled = Csp::isEnabled(); - $cspForm->on(ContractForm::ON_SUBMIT, function (CspConfigForm $form) use ($config, $wasCspEnabled) { - if ($form->hasConfigChanged() && ($form->isCspEnabled() || $wasCspEnabled)) { + $cspForm->on(ContractForm::ON_SUBMIT, function (CspConfigForm $form) { + if ($form->hasConfigChanged()) { $this->getResponse()->setReloadWindow(true); } Notification::success($this->translate('Content-Security-Policy updated')); diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 38c4baa5df..e259967f84 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -116,7 +116,7 @@ protected function assemble(): void . ' Enable Custom CSP checkbox above.', ), 'disabled' => true, - 'value' => Csp::getAutomaticHeaderValue($this->shouldIncludeUserContent()), + 'value' => Csp::getAutomaticHeader($this->shouldIncludeUserContent())->getHeader(), ]); $this->add(HtmlElement::create( From 021ad898d4a7718692a67668d28649fbb53f2483 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 20 Mar 2026 14:32:47 +0100 Subject: [PATCH 59/96] Use new Csp class in ipl-web --- library/Icinga/Security/Csp/LoadedCsp.php | 22 ++ .../Icinga/Security/Csp/Loader/CspLoader.php | 20 ++ .../Csp/Loader/DashboardCspLoader.php | 70 +++++ .../Security/Csp/Loader/ModuleCspLoader.php | 50 +++ .../Csp/Loader/NavigationCspLoader.php | 80 +++++ .../Security/Csp/Loader/StaticCspLoader.php | 35 +++ .../Icinga/Security/Csp/Reason/CspReason.php | 12 + .../Csp/Reason/DashboardCspReason.php | 24 ++ .../Security/Csp/Reason/ModuleCspReason.php | 19 ++ .../Csp/Reason/NavigationCspReason.php | 19 ++ .../Security/Csp/Reason/StaticCspReason.php | 19 ++ library/Icinga/Util/Csp.php | 287 +++--------------- .../Web/Widget/CspConfigurationTable.php | 98 +++--- 13 files changed, 473 insertions(+), 282 deletions(-) create mode 100644 library/Icinga/Security/Csp/LoadedCsp.php create mode 100644 library/Icinga/Security/Csp/Loader/CspLoader.php create mode 100644 library/Icinga/Security/Csp/Loader/DashboardCspLoader.php create mode 100644 library/Icinga/Security/Csp/Loader/ModuleCspLoader.php create mode 100644 library/Icinga/Security/Csp/Loader/NavigationCspLoader.php create mode 100644 library/Icinga/Security/Csp/Loader/StaticCspLoader.php create mode 100644 library/Icinga/Security/Csp/Reason/CspReason.php create mode 100644 library/Icinga/Security/Csp/Reason/DashboardCspReason.php create mode 100644 library/Icinga/Security/Csp/Reason/ModuleCspReason.php create mode 100644 library/Icinga/Security/Csp/Reason/NavigationCspReason.php create mode 100644 library/Icinga/Security/Csp/Reason/StaticCspReason.php diff --git a/library/Icinga/Security/Csp/LoadedCsp.php b/library/Icinga/Security/Csp/LoadedCsp.php new file mode 100644 index 0000000000..5fdee5de4b --- /dev/null +++ b/library/Icinga/Security/Csp/LoadedCsp.php @@ -0,0 +1,22 @@ +getUser(); + if ($user === null) { + throw new RuntimeException('No user logged in'); + } + + $dashboard = new Dashboard(); + $dashboard->setUser($user); + $dashboard->load(); + + $result = []; + + /** @var Dashboard\Pane $pane */ + foreach ($dashboard->getPanes() as $pane) { + /** @var Dashboard\Dashlet $dashlet */ + foreach ($pane->getDashlets() as $dashlet) { + $url = $dashlet->getUrl(); + if ($url === null) { + continue; + } + + $absoluteUrl = $url->isExternal() + ? $url->getAbsoluteUrl() + : $url->getParam('url'); + if ($absoluteUrl === null || filter_var($absoluteUrl, FILTER_VALIDATE_URL) === false) { + continue; + } + + $absoluteUrl = Url::fromPath($absoluteUrl); + + $cspUrl = $absoluteUrl->getScheme() . '://' . $absoluteUrl->getHost(); + if (($port = $absoluteUrl->getPort()) !== null) { + $cspUrl .= ':' . $port; + } + + $csp = new LoadedCsp(new DashboardCspReason($pane, $dashlet)); + $csp->add('frame-src', $cspUrl); + $result[] = $csp; + } + } + + return $result; + } +} diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php new file mode 100644 index 0000000000..9ec259c874 --- /dev/null +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -0,0 +1,50 @@ +getCspDirectives() as $directive => $policies) { + if (count($policies) === 0) { + continue; + } + + $csp->add($directive, $policies); + + $result[] = $csp; + } + } catch (Throwable $e) { + Logger::warning('Failed to invoke CSP hook: %s', $e->getMessage()); + } + } + + return $result; + } +} diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php new file mode 100644 index 0000000000..81fb9c0862 --- /dev/null +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -0,0 +1,80 @@ +isAuthenticated()) { + throw new RuntimeException('No user logged in'); + } + + $navigationType = Navigation::getItemTypeConfiguration(); + foreach ($navigationType as $type => $_) { + $navigation = new Navigation(); + foreach ($navigation->load($type) as $rootItem) { + foreach (self::yieldNavigation($rootItem) as $item) { + $url = $item->getUrl(); + $cspUrl = $url->getScheme() . '://' . $url->getHost(); + if (($port = $url->getPort()) !== null) { + $cspUrl .= ':' . $port; + } + + $csp = new LoadedCsp(new NavigationCspReason($type, $item)); + $csp->add('frame-src', $cspUrl); + $result[] = $csp; + } + } + } + + return $result; + } + + /** + * Recursively yield all navigation items that have an external URL. + * + * @param NavigationItem $item The top-level navigation item to start from. + * @return Generator + */ + protected static function yieldNavigation(NavigationItem $item): Generator + { + if ($item->hasChildren()) { + foreach ($item as $child) { + yield from self::yieldNavigation($child); + } + } + + $url = $item->getUrl(); + if ($url === null) { + return; + } + if ($item->getTarget() !== '_blank' && $url->isExternal()) { + yield $item; + } + } +} diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php new file mode 100644 index 0000000000..4370ba00c0 --- /dev/null +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -0,0 +1,35 @@ +name)); + foreach ($this->directives as $directive => $values) { + $csp->add($directive, $values); + } + + return [$csp]; + } +} diff --git a/library/Icinga/Security/Csp/Reason/CspReason.php b/library/Icinga/Security/Csp/Reason/CspReason.php new file mode 100644 index 0000000000..0aa546843d --- /dev/null +++ b/library/Icinga/Security/Csp/Reason/CspReason.php @@ -0,0 +1,12 @@ +styleNonce)) { + throw new RuntimeException('No nonce set for CSS'); + } + + $result = []; + $result = array_merge($result, (new StaticCspLoader( + 'system', + [ +// 'default-src' => ["'self'"], + 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], + 'font-src' => ["'self'", "data:"], + 'img-src' => ["'self'", "data:"], + 'frame-src' => ["'self'"], + ] + ))->load()); + + $result = array_merge($result, (new ModuleCspLoader())->load()); + if ($includeUserContent === null) { $includeUserContent = Config::app()->get('security', 'include_user_content', '0') === '1'; } - yield from self::yieldSystemOrigins(); - yield from self::yieldModuleOrigins(); + if ($includeUserContent) { - yield from self::yieldNavigationOrigins(); - yield from self::yieldDashletOrigins(); + $result = array_merge($result, (new DashboardCspLoader())->load()); + $result = array_merge($result, (new NavigationCspLoader())->load()); } + + return $result; } /** @@ -96,19 +108,19 @@ public static function getHeader(): string { $config = Config::app(); if ($config->get('security', 'use_custom_csp', '0') === '1') { - return self::getCustomHeaderValue(); + return self::getCustomHeader(); } - return self::getAutomaticHeaderValue(); + return self::getAutomaticHeader(); } /** * Get the custom Content-Security-Policy set in the config. * This method automatically replaces new-lines and the {style_nonce} placeholder with the generated nonce. * - * @return string Returns the custom CSP header value. + * @return CspInstance Returns the custom CSP header. */ - protected static function getCustomHeaderValue(): string + protected static function getCustomHeader(): CspInstance { $csp = static::getInstance(); @@ -122,37 +134,19 @@ protected static function getCustomHeaderValue(): string $customCsp = str_replace("\n", ' ', $customCsp); $customCsp = str_replace('{style_nonce}', "'nonce-{$csp->styleNonce}'", $customCsp); - return $customCsp; + return CspInstance::fromString($customCsp); } /** * Get the automatically generated Content-Security-Policy. * - * @return string Returns the generated header value. + * @return CspInstance Returns the generated header value. * @throws RuntimeException If no nonce set for CSS */ - public static function getAutomaticHeaderValue(?bool $includeUserContent = null): string + public static function getAutomaticHeader(?bool $includeUserContent = null): CspInstance { - $cspDirectives = []; - foreach (self::collectDirectives($includeUserContent) as $directive) { - foreach ($directive['directives'] as $directive => $policies) { - if (! isset($cspDirectives[$directive])) { - $cspDirectives[$directive] = []; - } - $cspDirectives[$directive] = array_merge($cspDirectives[$directive], $policies); - } - } - - $headerSegments = []; - foreach ($cspDirectives as $directive => $directivePolicies) { - if (empty($directivePolicies)) { - continue; - } - - $headerSegments[] = implode(' ', array_merge([$directive], array_unique($directivePolicies))); - } - - return implode('; ', $headerSegments); + $csps = self::load($includeUserContent); + return CspInstance::merge(...$csps); } /** @@ -209,203 +203,4 @@ protected static function getInstance(): self return static::$instance; } - - /** - * Yields the system origins. - * These directives should always be added first. - * - * @return Generator - */ - protected static function yieldSystemOrigins(): Generator - { - $csp = static::getInstance(); - - if (empty($csp->styleNonce)) { - throw new RuntimeException('No nonce set for CSS'); - } - - $items = [ - 'default-src' => ["'self'"], - 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], - 'font-src' => ["'self'", "data:"], - 'img-src' => ["'self'", "data:"], - 'frame-src' => ["'self'"], - ]; - - foreach ($items as $directive => $policies) { - yield [ - 'directives' => [ - $directive => $policies, - ], - 'reason' => [ - 'type' => 'system', - ] - ]; - } - } - - /** - * Yield all CSP directives from modules. See {@see CspDirectiveHook} for details. - * @return Generator - */ - protected static function yieldModuleOrigins(): Generator - { - // Allow modules to add their own csp directives in a limited fashion. - foreach (CspDirectiveHook::all() as $hook) { - $directives = []; - try { - foreach ($hook->getCspDirectives() as $directive => $policies) { - // policy names contain only lowercase letters and '-'. Reject anything else. - if (! preg_match('|^[a-z\-]+$|', $directive)) { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Invalid CSP directive found: $directive"); - continue; - } - - // The default-src can only ever be 'self'. Disallow any updates to it. - if ($directive === "default-src") { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Changing default-src is forbidden."); - continue; - } - - if (count($policies) === 0) { - continue; - } - - $directives[$directive] = $policies; - } - - if (count($directives) === 0) { - continue; - } - - yield [ - 'directives' => $directives, - 'reason' => [ - 'type' => 'module', - 'module' => ClassLoader::extractModuleName(get_class($hook)), - ], - ]; - } catch (Throwable $e) { - Logger::error('Failed to CSP hook on request: %s', $e); - } - } - } - - /** - * Fetches navigation items for the current user. - * - * Iterates through all registered navigation types, loads both user-specific - * and shared configurations, and returns a list of menu items. - * - * @return Generator A list of CSP directives, one for each navigation-item that has an external URL. - */ - protected static function yieldNavigationOrigins(): Generator - { - $auth = Auth::getInstance(); - if (! $auth->isAuthenticated()) { - return; - } - - $navigationType = Navigation::getItemTypeConfiguration(); - foreach ($navigationType as $type => $_) { - $navigation = new Navigation(); - foreach ($navigation->load($type) as $navItem) { - foreach (self::yieldNavigation($navItem) as $name => $url) { - $cspUrl = $url->getScheme() . '://' . $url->getHost(); - if (($port = $url->getPort()) !== null) { - $cspUrl .= ':' . $port; - } - yield [ - 'directives' => [ - 'frame-src' => [$cspUrl], - ], - 'reason' => [ - 'type' => 'navigation', - 'name' => $name, - 'parent' => $name !== $navItem->getName() ? $navItem->getName() : null, - 'navType' => $type, - ] - ]; - } - } - } - } - - /** - * Recursively yield all navigation items that have an external URL. - * - * @param NavigationItem $item The top-level navigation item to start from. - * @return Generator - */ - protected static function yieldNavigation(NavigationItem $item): Generator - { - if ($item->hasChildren()) { - foreach ($item as $child) { - yield from self::yieldNavigation($child); - } - } - - $url = $item->getUrl(); - if ($url === null) { - return; - } - if ($item->getTarget() !== '_blank' && $url->isExternal()) { - yield $item->getName() => $item->getUrl(); - } - } - - /** - * Fetches all dashlets for the current user that have an external URL. - * - * @return Generator A list of CSP directives, one for each dashlet that has an external URL. - */ - protected static function yieldDashletOrigins(): Generator - { - $user = Auth::getInstance()->getUser(); - if ($user === null) { - return; - } - - $dashboard = new Dashboard(); - $dashboard->setUser($user); - $dashboard->load(); - - /** @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; - } - - yield [ - 'directives' => [ - 'frame-src' => [$cspUrl], - ], - 'reason' => [ - 'type' => 'dashlet', - 'pane' => $pane->getName(), - 'dashlet' => $dashlet->getName(), - ] - ]; - } - } - } } diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index 7eca7f85d1..e03b4e1d9c 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -4,6 +4,12 @@ namespace Icinga\Web\Widget; +use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\Reason\CspReason; +use Icinga\Security\Csp\Reason\DashboardCspReason; +use Icinga\Security\Csp\Reason\ModuleCspReason; +use Icinga\Security\Csp\Reason\NavigationCspReason; +use Icinga\Security\Csp\Reason\StaticCspReason; use Icinga\Util\Csp; use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlElement; @@ -71,26 +77,31 @@ public function __construct( ) { } + /** + * @param string $title + * @param callable $filter + * @param LoadedCsp[] $csps + * @param array $header + * @param callable $rowBuilder + * + * @return void + */ protected function addPolicyTable( string $title, - string $filterType, - array $csp, + callable $filter, + array $csps, array $header, - callable $rowBuilder + callable $rowBuilder, ): void { $rows = []; - foreach ($csp as $row) { - $reason = $row['reason']; - $type = $reason['type']; - if ($type !== $filterType) { + foreach ($csps as $csp) { + if (! $filter($csp->loadReason)) { continue; } - foreach ($row['directives'] as $directive => $policies) { - if (count($policies) === 0) { - continue; - } - foreach ($policies as $k => $policy) { - $rows[] = $rowBuilder($reason, $directive, $policy); + foreach ($csp->getDirectives() as $directive => $policies) + { + foreach ($policies as $policy) { + $rows[] = $rowBuilder($csp->loadReason, $directive, $policy); } } } @@ -117,14 +128,17 @@ protected function addPolicyTable( protected function assemble(): void { - $csp = iterator_to_array(Csp::collectDirectives($this->includeUserContent), false); + $csps = Csp::load($this->includeUserContent); $this->addPolicyTable( t('System'), - 'system', - $csp, + function (CspReason $reason) { + return $reason instanceof StaticCspReason + && $reason->name === 'system'; + }, + $csps, [t('Directive'), t('Value')], - function (array $reason, string $directive, string $policy) { + function (StaticCspReason $reason, string $directive, string $policy) { return Table::tr([ Table::td($directive), $this->buildPolicy($directive, $policy), @@ -134,48 +148,60 @@ function (array $reason, string $directive, string $policy) { $this->addPolicyTable( t('Dashboard'), - 'dashlet', - $csp, + function (CspReason $reason) { + return $reason instanceof DashboardCspReason; + }, + $csps, [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], - function (array $reason, string $directive, string $policy) { + function (DashboardCspReason $reason, string $directive, string $policy) { return Table::tr([ - Table::td($reason['pane']), - Table::td($reason['dashlet']), + Table::td($reason->pane->getName()), + Table::td($reason->dashlet->getName()), Table::td($directive), $this->buildPolicy($directive, $policy), ]); - } + }, ); // TODO: Handle other types of navigation in extra tables $this->addPolicyTable( t('Navigation'), - 'navigation', - $csp, - [t('Type'), t('Name'), t('Parent'), t('Directive'), t('Value')], - function (array $reason, string $directive, string $policy) { + function (CspReason $reason) { + return $reason instanceof NavigationCspReason; + }, + $csps, + [t('Type'), t('Parent'), t('Name'), t('Directive'), t('Value')], + function (NavigationCspReason $reason, string $directive, string $policy) { + $parent = $reason->item->getParent(); + if ($parent === null) { + $parentCell = Table::td(t('None'))->setAttribute('class', 'empty-state'); + } else { + $parentCell = Table::td($parent->getName()); + } return Table::tr([ - Table::td($reason['navType']), - Table::td($reason['name']), - Table::td($reason['parent'] ?? t('NA')), + Table::td($reason->type), + $parentCell, + Table::td($reason->item->getName()), Table::td($directive), $this->buildPolicy($directive, $policy), ]); - } + }, ); $this->addPolicyTable( t('Modules'), - 'module', - $csp, + function (CspReason $reason) { + return $reason instanceof ModuleCspReason; + }, + $csps, [t('Module'), t('Directive'), t('Value')], - function (array $reason, string $directive, string $policy) { + function (ModuleCspReason $reason, string $directive, string $policy) { return Table::tr([ - Table::td($reason['module']), + Table::td($reason->module), Table::td($directive), $this->buildPolicy($directive, $policy), ]); - } + }, ); } From 084e4143a243b08c5a4b7689d0b501298a9d9686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 23 Mar 2026 11:24:58 +0100 Subject: [PATCH 60/96] Code style changes --- library/Icinga/Security/Csp/Loader/CspLoader.php | 2 +- library/Icinga/Util/Csp.php | 10 +++++----- library/Icinga/Web/Widget/CspConfigurationTable.php | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php index 3f804b7f07..686019c56f 100644 --- a/library/Icinga/Security/Csp/Loader/CspLoader.php +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -16,5 +16,5 @@ abstract class CspLoader * * @return LoadedCsp[] */ - public abstract function load(): array; + abstract public function load(): array; } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 1c9b32ea4a..57495ae5ba 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -76,11 +76,11 @@ public static function load(?bool $includeUserContent = null): array $result = array_merge($result, (new StaticCspLoader( 'system', [ -// 'default-src' => ["'self'"], - 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], - 'font-src' => ["'self'", "data:"], - 'img-src' => ["'self'", "data:"], - 'frame-src' => ["'self'"], + /* There is no need to define `default-src` here, as it is already defined in the base CSP */ + 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], + 'font-src' => ["'self'", "data:"], + 'img-src' => ["'self'", "data:"], + 'frame-src' => ["'self'"], ] ))->load()); diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index e03b4e1d9c..72f74a265a 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -98,8 +98,7 @@ protected function addPolicyTable( if (! $filter($csp->loadReason)) { continue; } - foreach ($csp->getDirectives() as $directive => $policies) - { + foreach ($csp->getDirectives() as $directive => $policies) { foreach ($policies as $policy) { $rows[] = $rowBuilder($csp->loadReason, $directive, $policy); } From c82760c63905832e4f6bebf13aa0ee8e83368ebc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 23 Mar 2026 12:53:59 +0100 Subject: [PATCH 61/96] Rework Csp to no longer rely on a private instance just to store the nonce --- library/Icinga/Util/Csp.php | 76 +++++++++++-------------------------- 1 file changed, 23 insertions(+), 53 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 57495ae5ba..016741d8aa 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -16,7 +16,6 @@ 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) @@ -31,11 +30,8 @@ */ class Csp { - /** @var self|null */ - protected static ?self $instance = null; - - /** @var ?string */ - protected ?string $styleNonce = null; + /** @var CspInstance|null */ + protected static ?CspInstance $csp = null; /** Singleton */ private function __construct() @@ -59,7 +55,7 @@ public static function addHeader(Response $response): void public static function isEnabled(): bool { - return Config::app()->get('security', 'use_strict_csp', '0') === '1'; + return Config::app()->get('security', 'use_strict_csp'); } /** @@ -67,8 +63,8 @@ public static function isEnabled(): bool */ public static function load(?bool $includeUserContent = null): array { - $csp = static::getInstance(); - if (empty($csp->styleNonce)) { + $nonce = static::getStyleNonce(); + if (empty($nonce)) { throw new RuntimeException('No nonce set for CSS'); } @@ -77,7 +73,7 @@ public static function load(?bool $includeUserContent = null): array 'system', [ /* There is no need to define `default-src` here, as it is already defined in the base CSP */ - 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], + 'style-src' => ["'self'", "'nonce-{$nonce}'"], 'font-src' => ["'self'", "data:"], 'img-src' => ["'self'", "data:"], 'frame-src' => ["'self'"], @@ -106,12 +102,16 @@ public static function load(?bool $includeUserContent = null): array */ public static function getHeader(): string { - $config = Config::app(); - if ($config->get('security', 'use_custom_csp', '0') === '1') { - return self::getCustomHeader(); + 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 self::getAutomaticHeader(); + return static::$csp->getHeader(); } /** @@ -122,17 +122,14 @@ public static function getHeader(): string */ protected static function getCustomHeader(): CspInstance { - $csp = static::getInstance(); - - if (empty($csp->styleNonce)) { + $nonce = static::getStyleNonce(); + if (empty($nonce)) { throw new RuntimeException('No nonce set for CSS'); } $config = Config::app(); $customCsp = $config->get('security', 'custom_csp', ''); - $customCsp = str_replace("\r\n", ' ', $customCsp); - $customCsp = str_replace("\n", ' ', $customCsp); - $customCsp = str_replace('{style_nonce}', "'nonce-{$csp->styleNonce}'", $customCsp); + $customCsp = str_replace('{style_nonce}', "'nonce-{$nonce}'", $customCsp); return CspInstance::fromString($customCsp); } @@ -157,10 +154,9 @@ public static function getAutomaticHeader(?bool $includeUserContent = null): Csp */ public static function createNonce(): void { - $csp = static::getInstance(); - if ($csp->styleNonce === null) { - $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); } } @@ -171,36 +167,10 @@ public static function createNonce(): void */ public static function getStyleNonce(): ?string { - if (Icinga::app()->isWeb()) { - return static::getInstance()->styleNonce; - } - return null; - } - - /** - * 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'); } } From e77025e0ab7aa25d939a733a4930839c4906c1b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 23 Mar 2026 13:01:26 +0100 Subject: [PATCH 62/96] Add form validation --- .../forms/Config/Security/CspConfigForm.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index e259967f84..27b239204d 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -4,12 +4,15 @@ namespace Icinga\Forms\Config\Security; +use Exception; use Icinga\Application\Config; use Icinga\Util\Csp; use Icinga\Web\Session; use Icinga\Web\Widget\CspConfigurationTable; use ipl\Html\HtmlElement; +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; @@ -88,6 +91,23 @@ protected function assemble(): void 'Set a custom CSP-Header. This completely overrides the automatically generated one.' . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.', ), + '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; + }), + ] ]); } else { $this->addElement('hidden', 'custom_csp'); From 2a7378bad6a2d0431acdc80583272b8d3c0d5394 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 24 Mar 2026 11:31:04 +0100 Subject: [PATCH 63/96] Merge CspConfigurationTable with form This allows for checkboxes integrated inside the table. This commit also adds disabling modules, dashboards and navigation items individualy. --- application/controllers/ConfigController.php | 4 +- .../forms/Config/Security/CspConfigForm.php | 530 +++++++++++++++--- library/Icinga/Util/Csp.php | 25 +- .../Web/Widget/CspConfigurationTable.php | 275 --------- public/css/icinga/csp-config-editor.less | 70 ++- 5 files changed, 524 insertions(+), 380 deletions(-) delete mode 100644 library/Icinga/Web/Widget/CspConfigurationTable.php diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 69f2cd943d..3826d3a4bb 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -137,7 +137,9 @@ public function securityAction(): void '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'), - 'include_user_content' => $config->get('security', 'include_user_content'), + '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) { diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 27b239204d..605bf4e176 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -6,10 +6,20 @@ use Exception; use Icinga\Application\Config; +use Icinga\Data\ConfigObject; +use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\Reason\CspReason; +use Icinga\Security\Csp\Reason\DashboardCspReason; +use Icinga\Security\Csp\Reason\ModuleCspReason; +use Icinga\Security\Csp\Reason\NavigationCspReason; +use Icinga\Security\Csp\Reason\StaticCspReason; use Icinga\Util\Csp; use Icinga\Web\Session; -use Icinga\Web\Widget\CspConfigurationTable; +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; @@ -17,23 +27,86 @@ use ipl\Web\Common\FormUid; use ipl\Web\Compat\CompatForm; use ipl\Web\Widget\Callout; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; class CspConfigForm extends CompatForm { use FormUid; use CsrfCounterMeasure; + /** @var string[] */ + protected const SECURE_KEYWORDS = [ + "'self'", + "'none'", + "'strict-dynamic'", + "'report-sample'", + "'report-sha256'", + "'report-sha384'", + "'report-sha512'", + ]; + + /** @var string[] */ + protected const WARNING_KEYWORDS = [ + "'unsafe-inline'", + "'unsafe-eval'", + "'unsafe-hashes'", + ]; + + /** @var string[] */ + protected const SECURE_SCHEMAS = [ + 'https', + 'wss', + ]; + + /** @var string[] */ + protected const WARNING_SCHEMAS = [ + 'http', + 'ws', + 'blob', + ]; + + /** @var string[] */ + protected const CRITICAL_DATA_DIRECTIVES = [ + 'default-src', + 'script-src', + 'object-src', + 'frame-src', + ]; + + /** @var string[] */ + protected const WARNING_DATA_DIRECTIVES = [ + 'style-src', + 'worker-src', + 'child-src', + 'base-uri', + ]; + + /** + * The number of rows for the CUSTOMS CSP textarea + * + * @const int + */ + protected const TEXTAREA_ROWS = 8; + protected bool $changed = false; public function __construct(protected Config $config) { - $this->setAttribute("name", "csp_config"); - $this->getAttributes()->add("class", "csp-config-form"); + $this->setAttribute('name', 'csp_config'); + $this->getAttributes()->add('class', 'csp-config-form'); $this->applyDefaultElementDecorators(); } protected function assemble(): void { + Csp::createNonce(); + $csps = Csp::load(new ConfigObject([ + 'csp_enable_modules' => '1', + 'csp_enable_dashboards' => '1', + 'csp_enable_navigation' => '1', + ])); + $this->addElement($this->createUidElement()); $this->addCsrfCounterMeasure(Session::getSession()->getId()); @@ -53,11 +126,141 @@ protected function assemble(): void ], ); + $disabledState = $this->getPopulatedValue('use_custom_csp') === '1'; + $disabledClass = $disabledState ? 'csp-disabled' : ''; + + $this->add(HtmlElement::create( + 'p', + ['class' => ['csp-form-hint', $disabledClass]], + $this->translate( + 'Enabling CSP will block some requests and prevent some functionality from working as expected.' + ), + )); + if (! $this->isCspEnabled()) { $this->addElement('hidden', 'use_custom_csp'); $this->addElement('hidden', 'custom_csp'); - $this->addElement('hidden', 'include_user_content'); + $this->addElement('hidden', 'csp_enable_modules'); + $this->addElement('hidden', 'csp_enable_dashboards'); + $this->addElement('hidden', 'csp_enable_navigation'); } else { + $this->add(HtmlElement::create( + 'h3', + ['class' => ['csp-form-hint', $disabledClass]], + $this->translate('Allowed Sources'), + )); + + $this->add(HtmlElement::create( + 'p', + ['class' => ['csp-form-hint', $disabledClass]], + $this->translate( + 'Sources that are used in the generation of the CSP-Header.' + ), + )); + + $this->addPolicyTable( + t('System'), + null, + null, + ! $disabledState, + function (CspReason $reason) { + return $reason instanceof StaticCspReason + && $reason->name === 'system'; + }, + $csps, + [t('Directive'), t('Value')], + function (StaticCspReason $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($directive), + $this->buildPolicy($directive, $policy), + ]); + }, + ); + + $this->addPolicyTable( + t('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, + function (CspReason $reason) { + return $reason instanceof ModuleCspReason; + }, + $csps, + [t('Module'), t('Directive'), t('Value')], + function (ModuleCspReason $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($reason->module), + Table::td($directive), + $this->buildPolicy($directive, $policy), + ]); + }, + ); + + $this->addPolicyTable( + t('Dashboard'), + $this->translate( + 'Enable user defined dashboards. Note: You will only be able to see your own dashboards,' + . ' and there is currently no way to see what others have configured for themselves.' + ), + 'csp_enable_dashboards', + ! $disabledState, + function (CspReason $reason) { + return $reason instanceof DashboardCspReason; + }, + $csps, + [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], + function (DashboardCspReason $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($reason->pane->getName()), + Table::td($reason->dashlet->getName()), + Table::td($directive), + $this->buildPolicy($directive, $policy), + ]); + }, + ); + + $this->addPolicyTable( + t('Navigation'), + $this->translate( + 'Enable navigation items. Note: You will only be able to see your own navigation items,' + . ' and there is currently no way to see what others have configured for themselves.' + ), + 'csp_enable_navigation', + ! $disabledState, + function (CspReason $reason) { + return $reason instanceof NavigationCspReason; + }, + $csps, + [t('Type'), t('Parent'), t('Name'), t('Directive'), t('Value')], + function (NavigationCspReason $reason, string $directive, string $policy) { + $parent = $reason->item->getParent(); + if ($parent === null) { + $parentCell = Table::td(t('None'))->setAttribute('class', 'empty-state'); + } else { + $parentCell = Table::td($parent->getName()); + } + return Table::tr([ + Table::td($reason->type), + $parentCell, + Table::td($reason->item->getName()), + Table::td($directive), + $this->buildPolicy($directive, $policy), + ]); + }, + ); + +// $this->add(HtmlElement::create( +// 'div', +// [ +// 'class' => 'collapsible', +// 'data-visible-height' => 250, +// ], +// $table, +// )); + $this->addElement( 'checkbox', 'use_custom_csp', @@ -66,15 +269,13 @@ protected function assemble(): void 'description' => $this->translate( 'Specify whether to use a custom, user provided, string as the CSP-Header.', ), - 'class' => 'autosubmit', + 'class' => 'autosubmit csp-form-content-aligned csp-label-header-h3 csp-form-header', 'checkedValue' => '1', 'uncheckedValue' => '0', ], ); if ($this->isCustomCspEnabled()) { - $this->addElement('hidden', 'include_user_content'); - $this->addHtml((new Callout( CalloutType::Warning, $this->translate( @@ -84,70 +285,34 @@ protected function assemble(): void ), $this->translate('Warning: Use at your own risk!'), ))->setFormElement()); + } - $this->addElement('textarea', 'custom_csp', [ - 'label' => $this->translate('Custom CSP'), - '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.', - ), - '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; - } - + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate(''), + 'description' => $this->translate( + 'Set a custom CSP-Header. This completely overrides the automatically generated one.' + . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.', + ), + 'rows' => static::TEXTAREA_ROWS, + 'disabled' => ! $this->isCustomCspEnabled(), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator) { + if (empty($value)) { return true; - }), - ] - ]); - } else { - $this->addElement('hidden', 'custom_csp'); + } - $this->addElement( - 'checkbox', - 'include_user_content', - [ - 'label' => $this->translate('Include User Content'), - 'description' => $this->translate( - 'If enabled, the user defined content like iframes in dashboards or ' - . 'menus will be included. Note: You will only be able to see the content that you ' - . 'have access to. There is no way to know what others have configured for themselves', - ), - 'class' => 'autosubmit', - 'checkedValue' => '1', - 'uncheckedValue' => '0', - ], - ); - - Csp::createNonce(); - $this->addElement('textarea', 'generated_csp', [ - 'label' => $this->translate('Generated CSP'), - 'description' => $this->translate( - 'This is the current CSP-Header. You can always safely go back to this by disabling the' - . ' Enable Custom CSP checkbox above.', - ), - 'disabled' => true, - 'value' => Csp::getAutomaticHeader($this->shouldIncludeUserContent())->getHeader(), - ]); + try { + $value = str_replace('{style_nonce}', "'nonce-validation'", $value); + CspInstance::fromString($value); + } catch (Exception $e) { + $validator->addMessage($e->getMessage()); + return false; + } - $this->add(HtmlElement::create( - 'div', - [ - 'class' => 'collapsible', - 'data-visible-height' => 250, - ], - new CspConfigurationTable($this->shouldIncludeUserContent()), - )); - } + return true; + }), + ] + ]); } $this->addElement('submit', 'submit', [ @@ -162,14 +327,11 @@ protected function onSuccess(): void $section = $config->getSection('security'); $beforeSection = clone $section; $section['use_strict_csp'] = $this->getValue('use_strict_csp'); - if ($this->isCspEnabled()) { - $section['use_custom_csp'] = $this->getValue('use_custom_csp'); - if ($this->isCustomCspEnabled()) { - $section['custom_csp'] = $this->getValue('custom_csp'); - } else { - $section['include_user_content'] = $this->getValue('include_user_content'); - } - } + $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), @@ -188,11 +350,6 @@ public function hasConfigChanged(): bool return $this->changed; } - public function shouldIncludeUserContent(): bool - { - return $this->getValue('include_user_content') === '1'; - } - public function isCspEnabled(): bool { return $this->getValue('use_strict_csp') === '1'; @@ -200,6 +357,217 @@ public function isCspEnabled(): bool public function isCustomCspEnabled(): bool { - return $this->getValue('use_custom_csp') === '1'; + return $this->getPopulatedValue('use_custom_csp') === '1'; + } + + /** + * @param string $title the title of the policy table + * @param string|null $description a short description of the section + * @param string|null $field the name of the checkbox to enable/disable the policy table + * @param bool $enabled is the section enabled? + * @param callable $filter a filter function to determine whether to include a policy in the table + * @param LoadedCsp[] $csps the loaded CSPs + * @param array $header the header of the table + * @param callable $rowBuilder a function to build a row of the table + * + * @return void + */ + protected function addPolicyTable( + string $title, + ?string $description, + ?string $field, + bool $enabled, + callable $filter, + array $csps, + array $header, + callable $rowBuilder, + ): void { + $disabledClass = $enabled ? '' : 'csp-disabled'; + + if ($field !== null) { + $this->addElement('checkbox', $field, [ + 'label' => sprintf($this->translate('Enable %s'), $title), + 'description' => $description, + 'class' => "autosubmit csp-form-content-aligned csp-label-header-h4 $disabledClass", + 'checkedValue' => '1', + 'uncheckedValue' => '0', + 'disabled' => ! $enabled, + ]); + + if ($disabledClass === '') { + $disabledClass = $this->getValue($field) ? '' : 'csp-disabled'; + } + } else { + $this->add(HtmlElement::create('h4', ['class' => "csp-form-hint $disabledClass"], $title)); + } + + $rows = []; + foreach ($csps as $csp) { + if (! $filter($csp->loadReason)) { + continue; + } + foreach ($csp->getDirectives() as $directive => $policies) { + foreach ($policies as $policy) { + $rows[] = $rowBuilder($csp->loadReason, $directive, $policy); + } + } + } + + if (count($rows) === 0) { + $this->add( + HtmlElement::create('p', ['class' => 'csp-form-hint'], sprintf('No %s policies found.', $title)) + ); + return; + } + + $table = new Table(); + $table->addAttributes(Attributes::create(['class' => ['csp-config-table', $disabledClass]])); + $headerRow = Table::tr(); + foreach ($header as $h) { + $headerRow->add(Table::th($h)); + } + $table->add($headerRow); + + foreach ($rows as $row) { + $table->add($row); + } + + $this->add($table); + } + + protected function getKeywordType(string $policy): ?string + { + if (in_array($policy, static::SECURE_KEYWORDS)) { + return 'secure'; + } + + if (in_array($policy, static::WARNING_KEYWORDS)) { + return 'warning'; + } + + return null; + } + + protected function getSchemeType(string $directive, string $policy): ?string + { + if (! str_ends_with($policy, ':')) { + return null; + } + + if (str_contains($policy, ' ')) { + return null; + } + + $schema = substr($policy, 0, -1); + + if (in_array($schema, static::SECURE_SCHEMAS)) { + return 'secure'; + } + + if (in_array($schema, static::WARNING_SCHEMAS)) { + return 'warning'; + } + + if ($schema === 'data' && in_array($directive, static::CRITICAL_DATA_DIRECTIVES)) { + return 'critical'; + } + + if ($schema === 'data' && in_array($directive, static::WARNING_DATA_DIRECTIVES)) { + return 'warning'; + } + + return 'unknown'; + } + + protected function isNonce(string $policy): bool + { + return (str_starts_with($policy, "'nonce-") && str_ends_with($policy, "'")); + } + + protected function buildPolicy(string $directive, string $policy): BaseHtmlElement + { + if ($policy === '*') { + $result = HtmlElement::create( + 'span', + ['class' => 'csp-wildcard'], + [ + $policy, + new Icon( + 'warning', + [ + 'class' => 'csp-policy-info', + 'title' => t( + 'This is a wildcard policy. It allows everything and should therefore be avoided.' + ), + ] + ), + ], + ); + } elseif (($keyword = $this->getKeywordType($policy)) !== null) { + $icon = match ($keyword) { + 'warning' => new Icon( + 'warning', + [ + 'class' => 'csp-policy-info', + 'title' => t('This is a potentially unsafe keyword.'), + ] + ), + default => null, + }; + $result = HtmlElement::create( + 'span', + ['class' => ['csp-keyword', 'csp-' . $keyword]], + [ + $policy, + $icon, + ] + ); + } elseif (($scheme = $this->getSchemeType($directive, $policy)) !== null) { + $icon = match ($scheme) { + 'warning' => new Icon( + 'warning', + [ + 'class' => 'csp-policy-info', + 'title' => t('This is a potentially unsafe scheme.'), + ] + ), + 'critical' => new Icon( + 'warning', + [ + 'class' => 'csp-policy-info', + 'title' => t('This is a critical scheme and should not be used.'), + ] + ), + default => null, + }; + $result = HtmlElement::create( + 'span', + ['class' => ['csp-scheme', 'csp-' . $scheme]], + [ + $policy, + $icon, + ] + ); + } elseif ($this->isNonce($policy)) { + $result = HtmlElement::create( + 'span', + ['class' => 'csp-nonce'], + [ + $policy, + new Icon( + 'info-circle', + [ + 'class' => 'csp-policy-info', + 'title' => t('This is an automatically generated nonce. Its value is unique per request.'), + ], + ), + ] + ); + } elseif (filter_var($policy, FILTER_VALIDATE_URL) !== false) { + $result = new Link($policy, $policy, ['target' => '_blank']); + } else { + $result = new Text($policy); + } + return Table::td($result, ['class' => 'csp-policies']); } } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 016741d8aa..e73519d078 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -7,6 +7,7 @@ use Icinga\Application\Config; use Icinga\Application\Icinga; +use Icinga\Data\ConfigObject; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Loader\DashboardCspLoader; use Icinga\Security\Csp\Loader\ModuleCspLoader; @@ -61,8 +62,12 @@ public static function isEnabled(): bool /** * @return LoadedCsp[] */ - public static function load(?bool $includeUserContent = null): array + public static function load(?ConfigObject $config = null): array { + if ($config === null) { + $config = Config::app()->getSection('security'); + } + $nonce = static::getStyleNonce(); if (empty($nonce)) { throw new RuntimeException('No nonce set for CSS'); @@ -80,14 +85,15 @@ public static function load(?bool $includeUserContent = null): array ] ))->load()); - $result = array_merge($result, (new ModuleCspLoader())->load()); - - if ($includeUserContent === null) { - $includeUserContent = Config::app()->get('security', 'include_user_content', '0') === '1'; + if ($config->get('csp_enable_modules', '1')) { + $result = array_merge($result, (new ModuleCspLoader())->load()); } - if ($includeUserContent) { + if ($config->get('csp_enable_dashboards', '1')) { $result = array_merge($result, (new DashboardCspLoader())->load()); + } + + if ($config->get('csp_enable_navigation', '1')) { $result = array_merge($result, (new NavigationCspLoader())->load()); } @@ -135,14 +141,15 @@ protected static function getCustomHeader(): CspInstance } /** - * Get the automatically generated Content-Security-Policy. + * Get the automatically generated Content-Security-Policy * * @return CspInstance Returns the generated header value. + * * @throws RuntimeException If no nonce set for CSS */ - public static function getAutomaticHeader(?bool $includeUserContent = null): CspInstance + protected static function getAutomaticHeader(): CspInstance { - $csps = self::load($includeUserContent); + $csps = self::load(); return CspInstance::merge(...$csps); } diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php deleted file mode 100644 index 72f74a265a..0000000000 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ /dev/null @@ -1,275 +0,0 @@ - 'csp-config-table']; - - /** @var string[] */ - protected const SECURE_KEYWORDS = [ - "'self'", - "'none'", - "'strict-dynamic'", - "'report-sample'", - "'report-sha256'", - "'report-sha384'", - "'report-sha512'", - ]; - - /** @var string[] */ - protected const WARNING_KEYWORDS = [ - "'unsafe-inline'", - "'unsafe-eval'", - "'unsafe-hashes'", - ]; - - /** @var string[] */ - protected const SECURE_SCHEMAS = [ - 'https', - 'wss', - ]; - - /** @var string[] */ - protected const WARNING_SCHEMAS = [ - 'http', - 'ws', - 'blob', - ]; - - /** @var string[] */ - protected const CRITICAL_DATA_DIRECTIVES = [ - 'default-src', - 'script-src', - 'object-src', - 'frame-src', - ]; - - /** @var string[] */ - protected const WARNING_DATA_DIRECTIVES = [ - 'style-src', - 'worker-src', - 'child-src', - 'base-uri', - ]; - - protected $tag = 'div'; - - public function __construct( - protected ?bool $includeUserContent = null, - ) { - } - - /** - * @param string $title - * @param callable $filter - * @param LoadedCsp[] $csps - * @param array $header - * @param callable $rowBuilder - * - * @return void - */ - protected function addPolicyTable( - string $title, - callable $filter, - array $csps, - array $header, - callable $rowBuilder, - ): void { - $rows = []; - foreach ($csps as $csp) { - if (! $filter($csp->loadReason)) { - continue; - } - foreach ($csp->getDirectives() as $directive => $policies) { - foreach ($policies as $policy) { - $rows[] = $rowBuilder($csp->loadReason, $directive, $policy); - } - } - } - - if (count($rows) === 0) { - return; - } - - $this->add(HtmlElement::create('h3', null, $title)); - - $table = new Table(); - $headerRow = Table::tr(); - foreach ($header as $h) { - $headerRow->add(Table::th($h)); - } - $table->add($headerRow); - - foreach ($rows as $row) { - $table->add($row); - } - - $this->add($table); - } - - protected function assemble(): void - { - $csps = Csp::load($this->includeUserContent); - - $this->addPolicyTable( - t('System'), - function (CspReason $reason) { - return $reason instanceof StaticCspReason - && $reason->name === 'system'; - }, - $csps, - [t('Directive'), t('Value')], - function (StaticCspReason $reason, string $directive, string $policy) { - return Table::tr([ - Table::td($directive), - $this->buildPolicy($directive, $policy), - ]); - }, - ); - - $this->addPolicyTable( - t('Dashboard'), - function (CspReason $reason) { - return $reason instanceof DashboardCspReason; - }, - $csps, - [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], - function (DashboardCspReason $reason, string $directive, string $policy) { - return Table::tr([ - Table::td($reason->pane->getName()), - Table::td($reason->dashlet->getName()), - Table::td($directive), - $this->buildPolicy($directive, $policy), - ]); - }, - ); - - // TODO: Handle other types of navigation in extra tables - $this->addPolicyTable( - t('Navigation'), - function (CspReason $reason) { - return $reason instanceof NavigationCspReason; - }, - $csps, - [t('Type'), t('Parent'), t('Name'), t('Directive'), t('Value')], - function (NavigationCspReason $reason, string $directive, string $policy) { - $parent = $reason->item->getParent(); - if ($parent === null) { - $parentCell = Table::td(t('None'))->setAttribute('class', 'empty-state'); - } else { - $parentCell = Table::td($parent->getName()); - } - return Table::tr([ - Table::td($reason->type), - $parentCell, - Table::td($reason->item->getName()), - Table::td($directive), - $this->buildPolicy($directive, $policy), - ]); - }, - ); - - $this->addPolicyTable( - t('Modules'), - function (CspReason $reason) { - return $reason instanceof ModuleCspReason; - }, - $csps, - [t('Module'), t('Directive'), t('Value')], - function (ModuleCspReason $reason, string $directive, string $policy) { - return Table::tr([ - Table::td($reason->module), - Table::td($directive), - $this->buildPolicy($directive, $policy), - ]); - }, - ); - } - - protected function getKeywordType(string $policy): ?string - { - if (in_array($policy, static::SECURE_KEYWORDS)) { - return 'secure'; - } - - if (in_array($policy, static::WARNING_KEYWORDS)) { - return 'warning'; - } - - return null; - } - - protected function getSchemeType(string $directive, string $policy): ?string - { - if (! str_ends_with($policy, ':')) { - return null; - } - - if (str_contains($policy, ' ')) { - return null; - } - - $schema = substr($policy, 0, -1); - - if (in_array($schema, static::SECURE_SCHEMAS)) { - return 'secure'; - } - - if (in_array($schema, static::WARNING_SCHEMAS)) { - return 'warning'; - } - - if ($schema === 'data' && in_array($directive, static::CRITICAL_DATA_DIRECTIVES)) { - return 'critical'; - } - - if ($schema === 'data' && in_array($directive, static::WARNING_DATA_DIRECTIVES)) { - return 'warning'; - } - - return 'unknown'; - } - - protected function isNonce(string $policy): bool - { - return (str_starts_with($policy, "'nonce-") && str_ends_with($policy, "'")); - } - - protected function buildPolicy(string $directive, string $policy): BaseHtmlElement - { - if ($policy === '*') { - $result = HtmlElement::create('span', ['class' => 'csp-wildcard'], $policy); - } elseif ($policy === "'self'") { - $result = HtmlElement::create('span', ['class' => 'csp-self'], $policy); - } elseif (($keyword = $this->getKeywordType($policy)) !== null) { - $result = HtmlElement::create('span', ['class' => ['csp-keyword', 'csp-' . $keyword]], $policy); - } elseif (($scheme = $this->getSchemeType($directive, $policy)) !== null) { - $result = HtmlElement::create('span', ['class' => ['csp-scheme', 'csp-' . $scheme]], $policy); - } elseif ($this->isNonce($policy)) { - $result = HtmlElement::create('span', ['class' => 'csp-nonce'], $policy); - } elseif (filter_var($policy, FILTER_VALIDATE_URL) !== false) { - $result = new Link($policy, $policy, ['target' => '_blank']); - } else { - $result = HtmlElement::create('span', null, $policy); - } - return Table::td($result, ['class' => 'csp-policies']); - } -} diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less index cadfcc5cd6..606f7362a2 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -3,6 +3,9 @@ // Layout .csp-config-table { + overflow-x: auto; + display: block; + h3 { margin-top: 0; @@ -11,10 +14,8 @@ } } - table { - width: 100%; - overflow-x: auto; - display: block; + th { + min-width: 6em; } th:first-child, @@ -33,6 +34,11 @@ justify-content: end; gap: 0.25em; } + + .csp-policy-info { + margin-left: .5em; + opacity: .7; + } } // Style @@ -71,14 +77,6 @@ color: @color-critical; } - .csp-secure { - color: @color-ok; - } - - .csp-nonce { - color: @color-unknown; - } - a { font-weight: bold; @@ -93,9 +91,53 @@ .csp-config-form { .csp-config-table { margin-left: 14em; + overflow-y: hidden; + } + + .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.556em 0 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; } - .btn-primary { - margin-top: 1em; + .control-group:has(.csp-form-header) { + margin-top: 2em; } } From 4386b950883cde2f2b0033a072255b085b6821ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 1 Apr 2026 09:43:06 +0200 Subject: [PATCH 64/96] Code review changes --- application/controllers/ConfigController.php | 4 ++-- application/forms/Config/Security/CspConfigForm.php | 2 +- library/Icinga/Security/Csp/Loader/CspLoader.php | 6 +++--- library/Icinga/Security/Csp/Loader/DashboardCspLoader.php | 2 +- library/Icinga/Security/Csp/Loader/ModuleCspLoader.php | 2 +- library/Icinga/Security/Csp/Loader/NavigationCspLoader.php | 2 +- library/Icinga/Security/Csp/Loader/StaticCspLoader.php | 2 +- library/Icinga/Security/Csp/Reason/CspReason.php | 4 ++-- library/Icinga/Security/Csp/Reason/DashboardCspReason.php | 6 +++--- library/Icinga/Security/Csp/Reason/ModuleCspReason.php | 4 ++-- library/Icinga/Security/Csp/Reason/NavigationCspReason.php | 6 +++--- library/Icinga/Security/Csp/Reason/StaticCspReason.php | 4 ++-- library/Icinga/Util/Csp.php | 3 +-- 13 files changed, 23 insertions(+), 24 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 3826d3a4bb..43e367fc37 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -129,14 +129,14 @@ public function securityAction(): void { $this->assertPermission('config/security'); - $this->view->title = $this->translate('General'); + $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'), + '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'), diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 605bf4e176..12701c7e6a 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -331,7 +331,7 @@ protected function onSuccess(): void $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'); + $section['custom_csp'] = $this->getValue('custom_csp', ''); $this->changed = ! empty(array_diff_assoc( iterator_to_array($section), diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php index 686019c56f..54ee094f67 100644 --- a/library/Icinga/Security/Csp/Loader/CspLoader.php +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -6,15 +6,15 @@ use Icinga\Security\Csp\LoadedCsp; /** - * Base class for CSP loaders. + * Interface for CSP loaders. * A loader is responsible for loading CSP directives from a specific source. */ -abstract class CspLoader +interface CspLoader { /** * Load the CSP directives from the source. * * @return LoadedCsp[] */ - abstract public function load(): array; + public function load(): array; } diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index ef0e3dc7ab..8c710049ab 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -16,7 +16,7 @@ * 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 extends CspLoader +class DashboardCspLoader implements CspLoader { /** * Fetches all dashlets for the current user that have an external URL. diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index 9ec259c874..91167cee54 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -15,7 +15,7 @@ * Modules can implement the {@see CspDirectiveHook} interface to provide custom CSP directives. * The hook is called for each request, allowing modules to dynamically add or modify CSP policies. */ -class ModuleCspLoader extends CspLoader +class ModuleCspLoader implements CspLoader { /** * List all CSP directives from modules. diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index 81fb9c0862..cd6bfb53bc 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -15,7 +15,7 @@ * 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 extends CspLoader +class NavigationCspLoader implements CspLoader { /** * Fetches navigation items for the current user. diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php index 4370ba00c0..565d8c519f 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -10,7 +10,7 @@ * Loads CSP directives from a static array. * Useful for testing or providing a static CSP configuration. */ -class StaticCspLoader extends CspLoader +class StaticCspLoader implements CspLoader { /** * @param string $name the name to display for CSP reason diff --git a/library/Icinga/Security/Csp/Reason/CspReason.php b/library/Icinga/Security/Csp/Reason/CspReason.php index 0aa546843d..d56b64df9b 100644 --- a/library/Icinga/Security/Csp/Reason/CspReason.php +++ b/library/Icinga/Security/Csp/Reason/CspReason.php @@ -4,9 +4,9 @@ namespace Icinga\Security\Csp\Reason; /** - * Base class for CSP reasons. + * Base interface for CSP reasons. * Only used for type hinting. */ -class CspReason +interface CspReason { } diff --git a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php index 3d06328384..495f1f1c18 100644 --- a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php +++ b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php @@ -10,15 +10,15 @@ * Reason for loading a CSP directive for a dashboard dashlet. * The CSP directive allows the iframe to be embedded on the page. */ -class DashboardCspReason extends CspReason +readonly class DashboardCspReason implements CspReason { /** * @param Pane $pane the pane that contains the dashlet * @param Dashlet $dashlet the dashlet to load the CSP directive for */ public function __construct( - public readonly Pane $pane, - public readonly Dashlet $dashlet, + public Pane $pane, + public Dashlet $dashlet, ) { } } diff --git a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php index a5d4e82425..d6dc7acb85 100644 --- a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php +++ b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php @@ -7,13 +7,13 @@ * Reason for loading a CSP directive for a module. * The CSP directive allows the module to be loaded. */ -class ModuleCspReason extends CspReason +readonly class ModuleCspReason implements CspReason { /** * @param string $module the module to load the CSP directive for */ public function __construct( - public readonly string $module, + public string $module, ) { } } diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index f9c8e8c462..1e69fd53de 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -9,11 +9,11 @@ * Reason for loading a CSP directive for a navigation item. * The CSP directive allows the iframe to be embedded on the page. */ -class NavigationCspReason extends CspReason +readonly class NavigationCspReason implements CspReason { public function __construct( - public readonly string $type, - public readonly NavigationItem $item, + public string $type, + public NavigationItem $item, ) { } } diff --git a/library/Icinga/Security/Csp/Reason/StaticCspReason.php b/library/Icinga/Security/Csp/Reason/StaticCspReason.php index 0716d3a8d2..ac8715dc2d 100644 --- a/library/Icinga/Security/Csp/Reason/StaticCspReason.php +++ b/library/Icinga/Security/Csp/Reason/StaticCspReason.php @@ -7,13 +7,13 @@ * A hardcoded CSP reason. * Useful for testing or providing a static CSP configuration. */ -class StaticCspReason extends CspReason +readonly class StaticCspReason implements CspReason { /** * @param string $name the name to display for CSP reason */ public function __construct( - public readonly string $name, + public string $name, ) { } } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index e73519d078..96c7a06e08 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -50,8 +50,7 @@ private function __construct() */ public static function addHeader(Response $response): void { - $header = static::getHeader(); - $response->setHeader('Content-Security-Policy', $header, true); + $response->setHeader('Content-Security-Policy', static::getHeader(), true); } public static function isEnabled(): bool From 2fe75bca5191565237afdca8d93b2d600822ac8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 18 May 2026 14:00:23 +0200 Subject: [PATCH 65/96] Rename schema to scheme --- .../forms/Config/Security/CspConfigForm.php | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 12701c7e6a..c97d50bcc2 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -54,13 +54,13 @@ class CspConfigForm extends CompatForm ]; /** @var string[] */ - protected const SECURE_SCHEMAS = [ + protected const SECURE_SCHEMES = [ 'https', 'wss', ]; /** @var string[] */ - protected const WARNING_SCHEMAS = [ + protected const WARNING_SCHEMES = [ 'http', 'ws', 'blob', @@ -458,21 +458,21 @@ protected function getSchemeType(string $directive, string $policy): ?string return null; } - $schema = substr($policy, 0, -1); + $scheme = substr($policy, 0, -1); - if (in_array($schema, static::SECURE_SCHEMAS)) { + if (in_array($scheme, static::SECURE_SCHEMES)) { return 'secure'; } - if (in_array($schema, static::WARNING_SCHEMAS)) { + if (in_array($scheme, static::WARNING_SCHEMES)) { return 'warning'; } - if ($schema === 'data' && in_array($directive, static::CRITICAL_DATA_DIRECTIVES)) { + if ($scheme === 'data' && in_array($directive, static::CRITICAL_DATA_DIRECTIVES)) { return 'critical'; } - if ($schema === 'data' && in_array($directive, static::WARNING_DATA_DIRECTIVES)) { + if ($scheme === 'data' && in_array($directive, static::WARNING_DATA_DIRECTIVES)) { return 'warning'; } From e6551e6610577010830250e7053890c21dad9cf7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 18 May 2026 14:25:34 +0200 Subject: [PATCH 66/96] Add rel="noopener noreferrer" `noopener` prevents the new page from accessing the original page's window object `noreferrer` hides the referrer information from the linked site --- application/forms/Config/Security/CspConfigForm.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index c97d50bcc2..13ba275952 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -564,7 +564,7 @@ protected function buildPolicy(string $directive, string $policy): BaseHtmlEleme ] ); } elseif (filter_var($policy, FILTER_VALIDATE_URL) !== false) { - $result = new Link($policy, $policy, ['target' => '_blank']); + $result = new Link($policy, $policy, ['target' => '_blank', 'rel' => 'noopener noreferrer']); } else { $result = new Text($policy); } From 6c250490550c1ecbb7ec7309922251cde50cc740 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 25 Mar 2026 10:26:43 +0100 Subject: [PATCH 67/96] Store security seection in config even if the section didn't exist before --- application/forms/Config/Security/CspConfigForm.php | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 13ba275952..1d5fc47ff5 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -322,9 +322,7 @@ function (NavigationCspReason $reason, string $directive, string $policy) { protected function onSuccess(): void { - $config = Config::app(); - - $section = $config->getSection('security'); + $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'); @@ -342,7 +340,9 @@ protected function onSuccess(): void return; } - $config->saveIni(); + $this->config->setSection('security', $section); + + $this->config->saveIni(); } public function hasConfigChanged(): bool @@ -392,6 +392,7 @@ protected function addPolicyTable( 'checkedValue' => '1', 'uncheckedValue' => '0', 'disabled' => ! $enabled, + 'value' => $this->getPopulatedValue($field), ]); if ($disabledClass === '') { From 497ba28080fea3e3ac925067a9a5a153f50b0b60 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 25 Mar 2026 10:27:04 +0100 Subject: [PATCH 68/96] Log errors during Csp loading --- library/Icinga/Util/Csp.php | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 96c7a06e08..407acd088b 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -5,8 +5,10 @@ namespace Icinga\Util; +use Exception; use Icinga\Application\Config; use Icinga\Application\Icinga; +use Icinga\Application\Logger; use Icinga\Data\ConfigObject; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Loader\DashboardCspLoader; @@ -55,7 +57,7 @@ public static function addHeader(Response $response): void public static function isEnabled(): bool { - return Config::app()->get('security', 'use_strict_csp'); + return (bool) Config::app()->get('security', 'use_strict_csp', '0'); } /** @@ -84,16 +86,28 @@ public static function load(?ConfigObject $config = null): array ] ))->load()); - if ($config->get('csp_enable_modules', '1')) { - $result = array_merge($result, (new ModuleCspLoader())->load()); + try { + if ($config->get('csp_enable_modules', '1')) { + $result = array_merge($result, (new ModuleCspLoader())->load()); + } + } catch (Exception $e) { + Logger::warning('Module CSP loader failed: %s', $e->getMessage()); } - if ($config->get('csp_enable_dashboards', '1')) { - $result = array_merge($result, (new DashboardCspLoader())->load()); + try { + if ($config->get('csp_enable_dashboards', '1')) { + $result = array_merge($result, (new DashboardCspLoader())->load()); + } + } catch (Exception $e) { + Logger::warning('Dashboard CSP loader failed: %s', $e->getMessage()); } - if ($config->get('csp_enable_navigation', '1')) { - $result = array_merge($result, (new NavigationCspLoader())->load()); + try { + if ($config->get('csp_enable_navigation', '1')) { + $result = array_merge($result, (new NavigationCspLoader())->load()); + } + } catch (Exception $e) { + Logger::warning('Navigation CSP loader failed: %s', $e->getMessage()); } return $result; From 0a7ad028e33e204da0f3417aea7a81ca0b856062 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 25 Mar 2026 11:16:47 +0100 Subject: [PATCH 69/96] Return Csp instances instead of raw arrays --- .../Application/Hook/CspDirectiveHook.php | 12 +++++------- library/Icinga/Security/Csp/LoadedCsp.php | 7 +++++++ .../Security/Csp/Loader/ModuleCspLoader.php | 17 +++++++---------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspDirectiveHook.php index e592cdc86b..aa974b53da 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspDirectiveHook.php @@ -5,6 +5,7 @@ namespace Icinga\Application\Hook; use Icinga\Application\Hook; +use ipl\Web\Common\Csp; /** * Allow modules to provide custom CSP directives. @@ -13,15 +14,12 @@ abstract class CspDirectiveHook { /** - * Allow the module to provide custom directives for the CSP header. The return value should be an array - * with a directive as the key and the policies in an array as the value. The valid values can either be - * a concrete host, allowlisting subdomains for hosts or custom nonce for that module. + * Allow the module to provide custom directives for the CSP header. The return value should be an instance of Csp + * with the requested directives. * - * Example: [ 'img-src' => [ 'https://*.icinga.com', 'https://example.com/' ] ] - * - * @return array The CSP directives are the keys and the policies the values. + * @return Csp a CSP instance, this instance will be merged with all other requested directives. */ - abstract public function getCspDirectives(): array; + abstract public function getCspDirectives(): Csp; /** * Get all registered implementations diff --git a/library/Icinga/Security/Csp/LoadedCsp.php b/library/Icinga/Security/Csp/LoadedCsp.php index 5fdee5de4b..66b1b7d2ec 100644 --- a/library/Icinga/Security/Csp/LoadedCsp.php +++ b/library/Icinga/Security/Csp/LoadedCsp.php @@ -19,4 +19,11 @@ public function __construct( public readonly CspReason $loadReason, ) { } + + public static function fromCsp(Csp $csp, CspReason $reason): static + { + $instance = new static($reason); + $instance->directives = $csp->directives; + return $instance; + } } diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index 91167cee54..9da20af74c 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -28,18 +28,15 @@ public function load(): array $result = []; foreach (CspDirectiveHook::all() as $hook) { - $reason = new ModuleCspReason(ClassLoader::extractModuleName(get_class($hook))); - $csp = new LoadedCsp($reason); try { - foreach ($hook->getCspDirectives() as $directive => $policies) { - if (count($policies) === 0) { - continue; - } - - $csp->add($directive, $policies); - - $result[] = $csp; + $csp = $hook->getCspDirectives(); + if ($csp->isEmpty()) { + continue; } + $result[] = LoadedCsp::fromCsp( + $csp, + new ModuleCspReason(ClassLoader::extractModuleName(get_class($hook))), + ); } catch (Throwable $e) { Logger::warning('Failed to invoke CSP hook: %s', $e->getMessage()); } From c5419763638352e9111d6110e216da45bcc9d10d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 25 Mar 2026 11:32:36 +0100 Subject: [PATCH 70/96] Change Hook name to CspHook --- .../Hook/{CspDirectiveHook.php => CspHook.php} | 16 ++++++++-------- .../Security/Csp/Loader/ModuleCspLoader.php | 14 +++++++------- 2 files changed, 15 insertions(+), 15 deletions(-) rename library/Icinga/Application/Hook/{CspDirectiveHook.php => CspHook.php} (58%) diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspHook.php similarity index 58% rename from library/Icinga/Application/Hook/CspDirectiveHook.php rename to library/Icinga/Application/Hook/CspHook.php index aa974b53da..5cb3b3e456 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspHook.php @@ -8,18 +8,18 @@ use ipl\Web\Common\Csp; /** - * Allow modules to provide custom CSP directives. + * Allow modules to provide custom Content-Security-Policy policies. * This hook is only used if the CSP header is enabled. */ -abstract class CspDirectiveHook +abstract class CspHook { /** - * Allow the module to provide custom directives for the CSP header. The return value should be an instance of Csp - * with the requested directives. + * Allow the module to provide custom directives and policies for the CSP header. + * The return value should be an instance of Csp with the requested policies. * * @return Csp a CSP instance, this instance will be merged with all other requested directives. */ - abstract public function getCspDirectives(): Csp; + abstract public function getCsp(): Csp; /** * Get all registered implementations @@ -28,16 +28,16 @@ abstract public function getCspDirectives(): Csp; */ public static function all(): array { - return Hook::all('CspDirective'); + return Hook::all('Csp'); } /** - * Register the class as a CspDirectiveHook implementation + * 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('CspDirective', static::class, static::class, true); + Hook::register('Csp', static::class, static::class, true); } } diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index 9da20af74c..74cce41c0c 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -4,22 +4,22 @@ namespace Icinga\Security\Csp\Loader; use Icinga\Application\ClassLoader; -use Icinga\Application\Hook\CspDirectiveHook; +use Icinga\Application\Hook\CspHook; use Icinga\Application\Logger; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Reason\ModuleCspReason; use Throwable; /** - * Loads CSP directives from modules. - * Modules can implement the {@see CspDirectiveHook} interface to provide custom CSP directives. - * The hook is called for each request, allowing modules to dynamically add or modify CSP policies. + * 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 { /** * List all CSP directives from modules. - * See {@see CspDirectiveHook} for details. + * See {@see CspHook} for details. * * @return LoadedCsp[] */ @@ -27,9 +27,9 @@ public function load(): array { $result = []; - foreach (CspDirectiveHook::all() as $hook) { + foreach (CspHook::all() as $hook) { try { - $csp = $hook->getCspDirectives(); + $csp = $hook->getCsp(); if ($csp->isEmpty()) { continue; } From 7b9fcbc2e484c784b82c8f53dcba1410c6de86a6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 25 Mar 2026 13:13:28 +0100 Subject: [PATCH 71/96] Make tables collapsible --- .../forms/Config/Security/CspConfigForm.php | 18 ++++++++---------- public/css/icinga/csp-config-editor.less | 3 ++- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 1d5fc47ff5..0e469c72ca 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -252,15 +252,6 @@ function (NavigationCspReason $reason, string $directive, string $policy) { }, ); -// $this->add(HtmlElement::create( -// 'div', -// [ -// 'class' => 'collapsible', -// 'data-visible-height' => 250, -// ], -// $table, -// )); - $this->addElement( 'checkbox', 'use_custom_csp', @@ -433,7 +424,14 @@ protected function addPolicyTable( $table->add($row); } - $this->add($table); + $this->add(HtmlElement::create( + 'div', + [ + 'class' => 'collapsible', + 'data-visible-height' => 100, + ], + $table, + )); } protected function getKeywordType(string $policy): ?string diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less index 606f7362a2..d49c81b40e 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -5,6 +5,7 @@ .csp-config-table { overflow-x: auto; display: block; + padding-bottom: 1em; h3 { margin-top: 0; @@ -125,7 +126,7 @@ // Form style .csp-config-form { .control-group:has(.csp-label-header-h3, .csp-label-header-h4) { - margin: 0.556em 0 0 + margin: 0; } .control-group:has(.csp-label-header-h3) .control-label-group label { From e6223bcd3e4422f276411fb83e5f619cbcc224cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 1 Apr 2026 08:55:08 +0200 Subject: [PATCH 72/96] Split title from table --- .../forms/Config/Security/CspConfigForm.php | 120 +++++++++++------- 1 file changed, 71 insertions(+), 49 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 0e469c72ca..4a1ec60198 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -158,38 +158,40 @@ protected function assemble(): void ), )); - $this->addPolicyTable( - t('System'), - null, - null, - ! $disabledState, + $this->addPolicyTitleElement($this->translate('System'), 'unused', null, ! $disabledState); + $this->addPolicyContentElement( + $csps, + [t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof StaticCspReason && $reason->name === 'system'; }, - $csps, - [t('Directive'), t('Value')], function (StaticCspReason $reason, string $directive, string $policy) { return Table::tr([ Table::td($directive), $this->buildPolicy($directive, $policy), ]); }, + ! $disabledState, + $this->translate('No system policies defined.') ); - $this->addPolicyTable( - t('Modules'), + $this->addPolicyTitleElement( + $this->translate('Modules'), $this->translate( 'Should module defined csp directives be enabled?' . ' Note: Modules can define or change csp directives at any point.' ), 'csp_enable_modules', ! $disabledState, + ); + + $this->addPolicyContentElement( + $csps, + [t('Module'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof ModuleCspReason; }, - $csps, - [t('Module'), t('Directive'), t('Value')], function (ModuleCspReason $reason, string $directive, string $policy) { return Table::tr([ Table::td($reason->module), @@ -197,21 +199,26 @@ function (ModuleCspReason $reason, string $directive, string $policy) { $this->buildPolicy($directive, $policy), ]); }, + $disabledState === false && $this->getValue('csp_enable_modules') === '1', + $this->translate('No module policies defined.') ); - $this->addPolicyTable( - t('Dashboard'), + $this->addPolicyTitleElement( + $this->translate('Dashboard'), $this->translate( 'Enable user defined dashboards. Note: You will only be able to see your own dashboards,' . ' and there is currently no way to see what others have configured for themselves.' ), 'csp_enable_dashboards', ! $disabledState, + ); + + $this->addPolicyContentElement( + $csps, + [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof DashboardCspReason; }, - $csps, - [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], function (DashboardCspReason $reason, string $directive, string $policy) { return Table::tr([ Table::td($reason->pane->getName()), @@ -220,21 +227,26 @@ function (DashboardCspReason $reason, string $directive, string $policy) { $this->buildPolicy($directive, $policy), ]); }, + $disabledState === false && $this->getValue('csp_enable_dashboards') === '1', + $this->translate('No dashboard policies found.'), ); - $this->addPolicyTable( - t('Navigation'), + $this->addPolicyTitleElement( + $this->translate('Navigation'), $this->translate( 'Enable navigation items. Note: You will only be able to see your own navigation items,' . ' and there is currently no way to see what others have configured for themselves.' ), 'csp_enable_navigation', ! $disabledState, + ); + + $this->addPolicyContentElement( + $csps, + [t('Navigation'), t('Parent'), t('Name'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof NavigationCspReason; }, - $csps, - [t('Type'), t('Parent'), t('Name'), t('Directive'), t('Value')], function (NavigationCspReason $reason, string $directive, string $policy) { $parent = $reason->item->getParent(); if ($parent === null) { @@ -250,6 +262,8 @@ function (NavigationCspReason $reason, string $directive, string $policy) { $this->buildPolicy($directive, $policy), ]); }, + $disabledState === false && $this->getValue('csp_enable_navigation') === '1', + $this->translate('No navigation policies found.'), ); $this->addElement( @@ -352,47 +366,55 @@ public function isCustomCspEnabled(): bool } /** - * @param string $title the title of the policy table - * @param string|null $description a short description of the section - * @param string|null $field the name of the checkbox to enable/disable the policy table - * @param bool $enabled is the section enabled? - * @param callable $filter a filter function to determine whether to include a policy in the table - * @param LoadedCsp[] $csps the loaded CSPs - * @param array $header the header of the table - * @param callable $rowBuilder a function to build a row of the table + * @param string $title the title of the section + * @param string|null $description the description of the section + * @param string|null $field the name of the checkbox that controls the section + * @param bool $enabled whether the section should be enabled * * @return void */ - protected function addPolicyTable( + protected function addPolicyTitleElement( string $title, ?string $description, ?string $field, bool $enabled, - callable $filter, - array $csps, - array $header, - callable $rowBuilder, ): void { $disabledClass = $enabled ? '' : 'csp-disabled'; - if ($field !== null) { - $this->addElement('checkbox', $field, [ - 'label' => sprintf($this->translate('Enable %s'), $title), - 'description' => $description, - 'class' => "autosubmit csp-form-content-aligned csp-label-header-h4 $disabledClass", - 'checkedValue' => '1', - 'uncheckedValue' => '0', - 'disabled' => ! $enabled, - 'value' => $this->getPopulatedValue($field), - ]); - - if ($disabledClass === '') { - $disabledClass = $this->getValue($field) ? '' : 'csp-disabled'; - } - } else { + if ($field == null) { $this->add(HtmlElement::create('h4', ['class' => "csp-form-hint $disabledClass"], $title)); + return; } + $this->addElement('checkbox', $field, [ + 'label' => sprintf($this->translate('Enable %s'), $title), + 'description' => $description, + 'class' => "autosubmit csp-form-content-aligned csp-label-header-h4 $disabledClass", + 'checkedValue' => '1', + 'uncheckedValue' => '0', + 'disabled' => ! $enabled, + 'value' => $this->getPopulatedValue($field), + ]); + } + + /** + * @param LoadedCsp[] $csps the list of cps along with their reasons + * @param string[] $header the header of the table + * @param callable $filter a filter function that returns true if the csp should be included in the table + * @param callable $rowBuilder a function that builds a row for the table + * @param bool $enabled whether the content should be enabled + * @param string $emptyText the text to display if there are no policies + * + * @return void + */ + protected function addPolicyContentElement( + array $csps, + array $header, + callable $filter, + callable $rowBuilder, + bool $enabled, + string $emptyText, + ): void { $rows = []; foreach ($csps as $csp) { if (! $filter($csp->loadReason)) { @@ -407,13 +429,13 @@ protected function addPolicyTable( if (count($rows) === 0) { $this->add( - HtmlElement::create('p', ['class' => 'csp-form-hint'], sprintf('No %s policies found.', $title)) + HtmlElement::create('p', ['class' => 'csp-form-hint'], $emptyText) ); return; } $table = new Table(); - $table->addAttributes(Attributes::create(['class' => ['csp-config-table', $disabledClass]])); + $table->addAttributes(Attributes::create(['class' => ['csp-config-table', $enabled ? '' : 'csp-disabled']])); $headerRow = Table::tr(); foreach ($header as $h) { $headerRow->add(Table::th($h)); From be1f91c6e7df980750f0bbfe68129a22ceff3bfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 1 Apr 2026 09:58:54 +0200 Subject: [PATCH 73/96] Indent polices if an icon exists in the table --- public/css/icinga/csp-config-editor.less | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less index d49c81b40e..2307ab6ec4 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -95,6 +95,15 @@ overflow-y: hidden; } + &:has(.csp-policies .icon) { + .csp-policies:not(:has(.icon)) { + padding-right: 2em; + } + th:last-child { + padding-right: 2em; + } + } + .csp-disabled, .control-group:has(.csp-disabled) { opacity: 0.5; From 16f2e94ecf768c9e22d140cbf1d3d764d4fed8a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 1 Apr 2026 10:26:49 +0200 Subject: [PATCH 74/96] Return an empty array instead of throwing an error --- library/Icinga/Security/Csp/Loader/DashboardCspLoader.php | 3 +-- library/Icinga/Security/Csp/Loader/NavigationCspLoader.php | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index 8c710049ab..2e6047e8e3 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -8,7 +8,6 @@ use Icinga\Security\Csp\Reason\DashboardCspReason; use Icinga\Web\Url; use Icinga\Web\Widget\Dashboard; -use RuntimeException; /** * This loader is responsible for loading CSP directives for external URLs in dashboard panes. @@ -27,7 +26,7 @@ public function load(): array { $user = Auth::getInstance()->getUser(); if ($user === null) { - throw new RuntimeException('No user logged in'); + return []; } $dashboard = new Dashboard(); diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index cd6bfb53bc..56544b75af 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -9,7 +9,6 @@ use Icinga\Security\Csp\Reason\NavigationCspReason; use Icinga\Web\Navigation\Navigation; use Icinga\Web\Navigation\NavigationItem; -use RuntimeException; /** * Loads CSP directives for navigation items that have an external URL. @@ -31,7 +30,7 @@ public function load(): array $auth = Auth::getInstance(); if (! $auth->isAuthenticated()) { - throw new RuntimeException('No user logged in'); + return []; } $navigationType = Navigation::getItemTypeConfiguration(); From 19274aefa08d713b1ff77d70974f3b7c118bc3d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 1 Apr 2026 14:50:57 +0200 Subject: [PATCH 75/96] Change license and use SPDX-Header --- application/forms/Config/Security/CspConfigForm.php | 3 ++- library/Icinga/Application/Hook/CspHook.php | 3 ++- library/Icinga/Security/Csp/LoadedCsp.php | 4 +++- library/Icinga/Security/Csp/Loader/CspLoader.php | 4 +++- library/Icinga/Security/Csp/Loader/DashboardCspLoader.php | 4 +++- library/Icinga/Security/Csp/Loader/ModuleCspLoader.php | 4 +++- library/Icinga/Security/Csp/Loader/NavigationCspLoader.php | 4 +++- library/Icinga/Security/Csp/Loader/StaticCspLoader.php | 4 +++- library/Icinga/Security/Csp/Reason/CspReason.php | 4 +++- library/Icinga/Security/Csp/Reason/DashboardCspReason.php | 4 +++- library/Icinga/Security/Csp/Reason/ModuleCspReason.php | 4 +++- library/Icinga/Security/Csp/Reason/NavigationCspReason.php | 4 +++- library/Icinga/Security/Csp/Reason/StaticCspReason.php | 4 +++- 13 files changed, 37 insertions(+), 13 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 4a1ec60198..5e9b9d2bdd 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -1,6 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Forms\Config\Security; diff --git a/library/Icinga/Application/Hook/CspHook.php b/library/Icinga/Application/Hook/CspHook.php index 5cb3b3e456..269b3a5f92 100644 --- a/library/Icinga/Application/Hook/CspHook.php +++ b/library/Icinga/Application/Hook/CspHook.php @@ -1,6 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Application\Hook; diff --git a/library/Icinga/Security/Csp/LoadedCsp.php b/library/Icinga/Security/Csp/LoadedCsp.php index 66b1b7d2ec..c9ba26cc80 100644 --- a/library/Icinga/Security/Csp/LoadedCsp.php +++ b/library/Icinga/Security/Csp/LoadedCsp.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp; diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php index 54ee094f67..6fe3f6bdbb 100644 --- a/library/Icinga/Security/Csp/Loader/CspLoader.php +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Loader; diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index 2e6047e8e3..f7e8aa4fad 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Loader; diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index 74cce41c0c..f4c9cb2f25 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Loader; diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index 56544b75af..14029aca6f 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Loader; diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php index 565d8c519f..6146ec4db3 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Loader; diff --git a/library/Icinga/Security/Csp/Reason/CspReason.php b/library/Icinga/Security/Csp/Reason/CspReason.php index d56b64df9b..ca9a9ff55f 100644 --- a/library/Icinga/Security/Csp/Reason/CspReason.php +++ b/library/Icinga/Security/Csp/Reason/CspReason.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Reason; diff --git a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php index 495f1f1c18..62ce62a8fa 100644 --- a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php +++ b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Reason; diff --git a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php index d6dc7acb85..3e16b3ecb9 100644 --- a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php +++ b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Reason; diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index 1e69fd53de..f279619527 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Reason; diff --git a/library/Icinga/Security/Csp/Reason/StaticCspReason.php b/library/Icinga/Security/Csp/Reason/StaticCspReason.php index ac8715dc2d..a85bd48bf6 100644 --- a/library/Icinga/Security/Csp/Reason/StaticCspReason.php +++ b/library/Icinga/Security/Csp/Reason/StaticCspReason.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Reason; From c483478d7d7fffae85a0f8680e49aa413bbd50f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 2 Apr 2026 09:29:35 +0200 Subject: [PATCH 76/96] Display the label of the navigation type instead of its internal type --- application/forms/Config/Security/CspConfigForm.php | 2 +- library/Icinga/Security/Csp/Loader/NavigationCspLoader.php | 6 +++--- library/Icinga/Security/Csp/Reason/NavigationCspReason.php | 6 ++++++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 5e9b9d2bdd..a8662617e2 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -256,7 +256,7 @@ function (NavigationCspReason $reason, string $directive, string $policy) { $parentCell = Table::td($parent->getName()); } return Table::tr([ - Table::td($reason->type), + Table::td($reason->typeConfiguration['label'] ?? $reason->type), $parentCell, Table::td($reason->item->getName()), Table::td($directive), diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index 14029aca6f..90cab3a0d6 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -35,8 +35,8 @@ public function load(): array return []; } - $navigationType = Navigation::getItemTypeConfiguration(); - foreach ($navigationType as $type => $_) { + $navigationTypes = Navigation::getItemTypeConfiguration(); + foreach ($navigationTypes as $type => $typeConfig) { $navigation = new Navigation(); foreach ($navigation->load($type) as $rootItem) { foreach (self::yieldNavigation($rootItem) as $item) { @@ -46,7 +46,7 @@ public function load(): array $cspUrl .= ':' . $port; } - $csp = new LoadedCsp(new NavigationCspReason($type, $item)); + $csp = new LoadedCsp(new NavigationCspReason($type, $typeConfig, $item)); $csp->add('frame-src', $cspUrl); $result[] = $csp; } diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index f279619527..5ac8576dc1 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -13,8 +13,14 @@ */ readonly class NavigationCspReason implements CspReason { + /** + * @param string $type the type of the navigation item + * @param array $typeConfiguration the configuration of the navigation item type + * @param NavigationItem $item the navigation item to load the CSP directive for + */ public function __construct( public string $type, + public array $typeConfiguration, public NavigationItem $item, ) { } From bb3a985037bfcedc6b8facdf3105ad2c1f67c3a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 26 Mar 2026 13:05:29 +0100 Subject: [PATCH 77/96] Write documentation --- doc/03-Configuration.md | 42 +++++++++++++++++++++++++++------------ doc/20-Advanced-Topics.md | 42 +++++++++++++++++++++++++++++---------- doc/60-Hooks.md | 30 ++++++++++++++++++++++++++++ 3 files changed, 91 insertions(+), 23 deletions(-) diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md index 89160bca0b..4aaf7b3a03 100644 --- a/doc/03-Configuration.md +++ b/doc/03-Configuration.md @@ -41,19 +41,6 @@ config_resource = "icingaweb_db" module_path = "/usr/share/icingaweb2/modules" ``` -### Security Configuration - -| Option | Description | -|------------------|---------------------------------------------------------------------------------------------------------------------------------------| -| use\_strict\_csp | **Optional.** Set this to `1` to enable strict [Content Security Policy](20-Advanced-Topics.md#advanced-topics-csp). Defaults to `0`. | - -Example: - -``` -[security] -use_strict_csp = "1" -``` - ### Logging Configuration Option | Description @@ -87,3 +74,32 @@ Example: disabled = "1" default = "high-contrast" ``` + +## Security Configuration + +Navigate into **Configuration > Application > Security**. + +This configuration is stored in the `config.ini` file in `/etc/icingaweb2`. + +### Content Security Policy Configuration + +| Option | Description | +|-------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------| +| use\_strict\_csp | **Optional.** Set this to `1` to enable strict [Content Security Policy](20-Advanced-Topics.md#advanced-topics-csp). Defaults to `0`. | +| use\_custom\_csp | **Optional.** Set this to `1` to enable the use of the user defined Content Security Policy. Defaults to `0`. | +| custom\_csp | **Optional.** Specifies the user defined Content Security Policy. Overrides the automatically generated one. Only used if `use_custom_csp` is set to `1`. | +| csp\_enable\_modules | **Optional.** Specifies if modules should be included in the generated Content Security Policy. Defaults to `1`. | +| csp\_enable\_dashboards | **Optional.** Specifies if dashboards should be included in the generated Content Security Policy. Defaults to `1`. | +| csp\_enable\_navigation | **Optional.** Specifies if navigation menu items should be included in the generated Content Security Policy. Defaults to `1`. | + +Example: + +``` +[security] +use_strict_csp = "1" +use_custom_csp = "0" +custom_csp = "frame-src https://example.com" +csp_enable_modules = "1" +csp_enable_dashboards = "1" +csp_enable_navigation = "1" +``` diff --git a/doc/20-Advanced-Topics.md b/doc/20-Advanced-Topics.md index a144a5be01..88b47d6ca1 100644 --- a/doc/20-Advanced-Topics.md +++ b/doc/20-Advanced-Topics.md @@ -117,24 +117,35 @@ systemctl reload httpd Elevate your security standards to an even higher level by enabling the [Content Security Policy (CSP)](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) for Icinga Web. Enabling strict CSP can prevent your Icinga Web environment from becoming a potential target of [Cross-Site Scripting (XSS)](https://developer.mozilla.org/en-US/docs/Glossary/Cross-site_scripting) -and data injection attacks. After enabling this feature Icinga Web defines all the required CSP headers. Subsequently, +and data injection attacks. After enabling this feature, Icinga Web defines all the required CSP headers. Subsequently, only content coming from Icinga Web's own origin is accepted, inline JS is prohibited, and inline CSS is accepted only if it contains the nonce set in the response header. We decided against enabling this by default as we cannot guarantee that all the modules out there will function correctly. Therefore, you have to manually enable this policy explicitly and accept the risks that this might break some of -the Icinga Web modules. Icinga Web and all it's components listed below, on the other hand, fully support strict CSP. If +the Icinga Web modules. Icinga Web and all its components listed below, on the other hand, fully support strict CSP. If that's not the case, please submit an issue on GitHub in the respective repositories. -To enable the strict content security policy navigate to **Configuration > Application** and toggle "Enable strict content security policy", -or set the `use_strict_csp` in the `config.ini`. +To enable the strict content security policy, navigate to **Configuration > Application > Security** and toggle +"Send CSP-Header", or set `use_strict_csp` in the `config.ini`. -``` -vim /etc/icingaweb2/config.ini +Icinga does its best to support user-defined content like navigation items and dashboard dashlets. If that behavior is +not desired, you can disable both by disabling the corresponding feature in the **Security page** at +**Configuration > Application > Security** or by setting `csp_enable_navigation` or `csp_enable_dashboards` in the +`config.ini`. Note that while you can see all navigation items and dashboards, the actual CSP is generated per user +and does not include the full set of directives shown. -[security] -use_strict_csp = "1" -``` +If it is necessary to add extra entries to the CSP header, you can do so by using the `CspHook` hook, +read more about it [here](60-Hooks.md#hooks-csp). This is the preferred way to extend the CSP header +because it is an additive and modular approach. + +Alternatively you can define your own CSP header by setting the `custom_csp` in the `config.ini` or by configuring the +`Custom CSP` section at **Configuration > Application > Security** which will completely overwrite the generated +CSP header. +Therefore, you are responsible for ensuring that the CSP header is valid, does not contain insecure directives, +is kept up to date with updates or changes to the icingaweb application or its components, and works for every user. +When creating your own CSP header, you can use the placeholder `{style_nonce}` in place of the +automatically generated nonce. This will be replaced with the actual nonce when a user loads icingaweb. Here is a list of all Icinga Web components that are capable of strict CSP. @@ -155,6 +166,17 @@ Here is a list of all Icinga Web components that are capable of strict CSP. | Icinga Web AWS Integration | [v1.1.0](https://github.com/Icinga/icingaweb2-module-aws/releases/tag/v1.1.0) | | Icinga Web vSphere Integration | [v1.8.0](https://github.com/Icinga/icingaweb2-module-vspheredb/releases/tag/v1.8.0) | +``` +vim /etc/icingaweb2/config.ini + +[security] +use_strict_csp = "1" +csp_enable_modules = "1" +csp_enable_dashboards = "1" +csp_enable_navigation = "1" +use_custom_csp = "0" +custom_csp = "" +``` ## Advanced Authentication Tips @@ -318,7 +340,7 @@ which may help you already: If you are automating the installation of Icinga Web 2, you may want to skip the wizard and do things yourself. These are the steps you'd need to take assuming you are using MySQL/MariaDB. If you are using PostgreSQL please adapt -accordingly. Note you need to have successfully completed the Icinga 2 installation, installed the Icinga Web 2 packages +accordingly. Note you need to have successfully completed the Icinga 2 installation, installed the Icinga Web 2 packages, and all the other steps described above first. 1. Install PHP dependencies: `php`, `php-intl`, `php-imagick`, `php-gd`, `php-mysql`, `php-curl`, `php-mbstring` used diff --git a/doc/60-Hooks.md b/doc/60-Hooks.md index 2dc645d992..f1e7ef0504 100644 --- a/doc/60-Hooks.md +++ b/doc/60-Hooks.md @@ -47,3 +47,33 @@ class ConfigFormEvents extends ConfigFormEventsHook } } ``` + +## CspHook + +The `CspHook` allows developers to add custom CSP directives to the Icinga Web 2 frontend. +It provides the method `getCsp()` which should return a `Csp` instance with the directives the module wants to add. +The directives are combined additively with the default directives, icingaweb2 generated ones and other module-defined +directives. + +Hook example: + +```php +namespace Icinga\Module\Acme\ProvidedHook; + +use Icinga\Application\Hook\CspHook; +use ipl\Web\Common\Csp as CspInstance; + +class Csp extends CspHook +{ + public function getCsp(): CspInstance + { + $csp = new CspInstance(); + $csp->add('img-src', ['cdn.example.com', 'usercontent.example.com']); + $csp->add('style-src', 'cdn.example.com'); + + // ... + + return $csp; + } +} +``` From 25f6fc53d8ecb39b7d0f0539938dfe83cab904d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 9 Apr 2026 10:06:18 +0200 Subject: [PATCH 78/96] Change policy to expression to be more spec compliant --- .../forms/Config/Security/CspConfigForm.php | 98 +++++++++---------- public/css/icinga/csp-config-editor.less | 8 +- 2 files changed, 53 insertions(+), 53 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index a8662617e2..b420b6036c 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -159,25 +159,25 @@ protected function assemble(): void ), )); - $this->addPolicyTitleElement($this->translate('System'), 'unused', null, ! $disabledState); - $this->addPolicyContentElement( + $this->addDirectiveTitleElement($this->translate('System'), 'unused', null, ! $disabledState); + $this->addDirectiveContentElement( $csps, [t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof StaticCspReason && $reason->name === 'system'; }, - function (StaticCspReason $reason, string $directive, string $policy) { + function (StaticCspReason $reason, string $directive, string $expression) { return Table::tr([ Table::td($directive), - $this->buildPolicy($directive, $policy), + $this->buildExpression($directive, $expression), ]); }, ! $disabledState, $this->translate('No system policies defined.') ); - $this->addPolicyTitleElement( + $this->addDirectiveTitleElement( $this->translate('Modules'), $this->translate( 'Should module defined csp directives be enabled?' @@ -187,24 +187,24 @@ function (StaticCspReason $reason, string $directive, string $policy) { ! $disabledState, ); - $this->addPolicyContentElement( + $this->addDirectiveContentElement( $csps, [t('Module'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof ModuleCspReason; }, - function (ModuleCspReason $reason, string $directive, string $policy) { + function (ModuleCspReason $reason, string $directive, string $expression) { return Table::tr([ Table::td($reason->module), Table::td($directive), - $this->buildPolicy($directive, $policy), + $this->buildExpression($directive, $expression), ]); }, $disabledState === false && $this->getValue('csp_enable_modules') === '1', $this->translate('No module policies defined.') ); - $this->addPolicyTitleElement( + $this->addDirectiveTitleElement( $this->translate('Dashboard'), $this->translate( 'Enable user defined dashboards. Note: You will only be able to see your own dashboards,' @@ -214,25 +214,25 @@ function (ModuleCspReason $reason, string $directive, string $policy) { ! $disabledState, ); - $this->addPolicyContentElement( + $this->addDirectiveContentElement( $csps, [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof DashboardCspReason; }, - function (DashboardCspReason $reason, string $directive, string $policy) { + function (DashboardCspReason $reason, string $directive, string $expression) { return Table::tr([ Table::td($reason->pane->getName()), Table::td($reason->dashlet->getName()), Table::td($directive), - $this->buildPolicy($directive, $policy), + $this->buildExpression($directive, $expression), ]); }, $disabledState === false && $this->getValue('csp_enable_dashboards') === '1', $this->translate('No dashboard policies found.'), ); - $this->addPolicyTitleElement( + $this->addDirectiveTitleElement( $this->translate('Navigation'), $this->translate( 'Enable navigation items. Note: You will only be able to see your own navigation items,' @@ -242,13 +242,13 @@ function (DashboardCspReason $reason, string $directive, string $policy) { ! $disabledState, ); - $this->addPolicyContentElement( + $this->addDirectiveContentElement( $csps, [t('Navigation'), t('Parent'), t('Name'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof NavigationCspReason; }, - function (NavigationCspReason $reason, string $directive, string $policy) { + function (NavigationCspReason $reason, string $directive, string $expression) { $parent = $reason->item->getParent(); if ($parent === null) { $parentCell = Table::td(t('None'))->setAttribute('class', 'empty-state'); @@ -260,7 +260,7 @@ function (NavigationCspReason $reason, string $directive, string $policy) { $parentCell, Table::td($reason->item->getName()), Table::td($directive), - $this->buildPolicy($directive, $policy), + $this->buildExpression($directive, $expression), ]); }, $disabledState === false && $this->getValue('csp_enable_navigation') === '1', @@ -374,7 +374,7 @@ public function isCustomCspEnabled(): bool * * @return void */ - protected function addPolicyTitleElement( + protected function addDirectiveTitleElement( string $title, ?string $description, ?string $field, @@ -408,7 +408,7 @@ protected function addPolicyTitleElement( * * @return void */ - protected function addPolicyContentElement( + protected function addDirectiveContentElement( array $csps, array $header, callable $filter, @@ -421,9 +421,9 @@ protected function addPolicyContentElement( if (! $filter($csp->loadReason)) { continue; } - foreach ($csp->getDirectives() as $directive => $policies) { - foreach ($policies as $policy) { - $rows[] = $rowBuilder($csp->loadReason, $directive, $policy); + foreach ($csp->getDirectives() as $directive => $expressions) { + foreach ($expressions as $expression) { + $rows[] = $rowBuilder($csp->loadReason, $directive, $expression); } } } @@ -457,30 +457,30 @@ protected function addPolicyContentElement( )); } - protected function getKeywordType(string $policy): ?string + protected function getKeywordType(string $expression): ?string { - if (in_array($policy, static::SECURE_KEYWORDS)) { + if (in_array($expression, static::SECURE_KEYWORDS)) { return 'secure'; } - if (in_array($policy, static::WARNING_KEYWORDS)) { + if (in_array($expression, static::WARNING_KEYWORDS)) { return 'warning'; } return null; } - protected function getSchemeType(string $directive, string $policy): ?string + protected function getSchemeType(string $directive, string $expression): ?string { - if (! str_ends_with($policy, ':')) { + if (! str_ends_with($expression, ':')) { return null; } - if (str_contains($policy, ' ')) { + if (str_contains($expression, ' ')) { return null; } - $scheme = substr($policy, 0, -1); + $scheme = substr($expression, 0, -1); if (in_array($scheme, static::SECURE_SCHEMES)) { return 'secure'; @@ -501,36 +501,36 @@ protected function getSchemeType(string $directive, string $policy): ?string return 'unknown'; } - protected function isNonce(string $policy): bool + protected function isNonce(string $expression): bool { - return (str_starts_with($policy, "'nonce-") && str_ends_with($policy, "'")); + return (str_starts_with($expression, "'nonce-") && str_ends_with($expression, "'")); } - protected function buildPolicy(string $directive, string $policy): BaseHtmlElement + protected function buildExpression(string $directive, string $expression): BaseHtmlElement { - if ($policy === '*') { + if ($expression === '*') { $result = HtmlElement::create( 'span', ['class' => 'csp-wildcard'], [ - $policy, + $expression, new Icon( 'warning', [ - 'class' => 'csp-policy-info', + 'class' => 'csp-expression-info', 'title' => t( - 'This is a wildcard policy. It allows everything and should therefore be avoided.' + 'This is a wildcard expression. It allows everything and should therefore be avoided.' ), ] ), ], ); - } elseif (($keyword = $this->getKeywordType($policy)) !== null) { + } elseif (($keyword = $this->getKeywordType($expression)) !== null) { $icon = match ($keyword) { 'warning' => new Icon( 'warning', [ - 'class' => 'csp-policy-info', + 'class' => 'csp-expression-info', 'title' => t('This is a potentially unsafe keyword.'), ] ), @@ -540,23 +540,23 @@ protected function buildPolicy(string $directive, string $policy): BaseHtmlEleme 'span', ['class' => ['csp-keyword', 'csp-' . $keyword]], [ - $policy, + $expression, $icon, ] ); - } elseif (($scheme = $this->getSchemeType($directive, $policy)) !== null) { + } elseif (($scheme = $this->getSchemeType($directive, $expression)) !== null) { $icon = match ($scheme) { 'warning' => new Icon( 'warning', [ - 'class' => 'csp-policy-info', + 'class' => 'csp-expression-info', 'title' => t('This is a potentially unsafe scheme.'), ] ), 'critical' => new Icon( 'warning', [ - 'class' => 'csp-policy-info', + 'class' => 'csp-expression-info', 'title' => t('This is a critical scheme and should not be used.'), ] ), @@ -566,30 +566,30 @@ protected function buildPolicy(string $directive, string $policy): BaseHtmlEleme 'span', ['class' => ['csp-scheme', 'csp-' . $scheme]], [ - $policy, + $expression, $icon, ] ); - } elseif ($this->isNonce($policy)) { + } elseif ($this->isNonce($expression)) { $result = HtmlElement::create( 'span', ['class' => 'csp-nonce'], [ - $policy, + $expression, new Icon( 'info-circle', [ - 'class' => 'csp-policy-info', + 'class' => 'csp-expression-info', 'title' => t('This is an automatically generated nonce. Its value is unique per request.'), ], ), ] ); - } elseif (filter_var($policy, FILTER_VALIDATE_URL) !== false) { - $result = new Link($policy, $policy, ['target' => '_blank', 'rel' => 'noopener noreferrer']); + } elseif (filter_var($expression, FILTER_VALIDATE_URL) !== false) { + $result = new Link($expression, $expression, ['target' => '_blank', 'rel' => 'noopener noreferrer']); } else { - $result = new Text($policy); + $result = new Text($expression); } - return Table::td($result, ['class' => 'csp-policies']); + return Table::td($result, ['class' => 'csp-expressions']); } } diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less index 2307ab6ec4..b780d2e603 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -29,14 +29,14 @@ width: 100%; } - .csp-policies { + .csp-expressions { display: flex; flex-direction: row; justify-content: end; gap: 0.25em; } - .csp-policy-info { + .csp-expression-info { margin-left: .5em; opacity: .7; } @@ -95,8 +95,8 @@ overflow-y: hidden; } - &:has(.csp-policies .icon) { - .csp-policies:not(:has(.icon)) { + &:has(.csp-expressions .icon) { + .csp-expressions:not(:has(.icon)) { padding-right: 2em; } th:last-child { From fd4d0f71375521f75d21728f218b8438a5a3d05d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 18 May 2026 15:34:37 +0200 Subject: [PATCH 79/96] Add helper methods for accessing the currently active csp configuration --- library/Icinga/Util/Csp.php | 62 +++++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 407acd088b..c556e818b2 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -61,6 +61,68 @@ public static function isEnabled(): bool } /** + * 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'); + } + + /** + * Load configured CSP policies * @return LoadedCsp[] */ public static function load(?ConfigObject $config = null): array From 0ab7f70d7263a0646bce3541c0fbb54510973286 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 15 Apr 2026 16:12:59 +0200 Subject: [PATCH 80/96] Display an info callout when adding dashlets or custom navigation items --- application/forms/Dashboard/DashletForm.php | 21 +++++++++++++++++++ .../forms/Navigation/NavigationItemForm.php | 21 +++++++++++++++++++ library/Icinga/Util/Csp.php | 4 ++++ 3 files changed, 46 insertions(+) diff --git a/application/forms/Dashboard/DashletForm.php b/application/forms/Dashboard/DashletForm.php index 02cba4ce0f..3ad94ed8b7 100644 --- a/application/forms/Dashboard/DashletForm.php +++ b/application/forms/Dashboard/DashletForm.php @@ -5,12 +5,15 @@ namespace Icinga\Forms\Dashboard; +use Icinga\Util\Csp; use Icinga\Web\Form; use Icinga\Web\Form\Validator\InternalUrlValidator; use Icinga\Web\Form\Validator\UrlValidator; use Icinga\Web\Url; use Icinga\Web\Widget\Dashboard; use Icinga\Web\Widget\Dashboard\Dashlet; +use ipl\Web\Common\CalloutType; +use ipl\Web\Widget\Callout; /** * Form to add an url a dashboard pane @@ -75,6 +78,24 @@ public function createElements(array $formData) ) ); + if (Csp::isEnabled() && ! Csp::isDashboardEnabled()) { + $this->addElement( + 'note', + 'csp_warning', + [ + 'decorators' => ['ViewHelper'], + 'value' => (new Callout( + CalloutType::Info, + $this->translate( + 'Any external url is not guaranteed to work as expected. ' + . 'Please make sure to check the Content-Security-Policy configuration.' + ), + $this->translate('Dashboards are not enabled in the CSP configuration'), + ))->setFormElement()->render(), + ] + ); + } + $this->addElement( 'textarea', 'url', diff --git a/application/forms/Navigation/NavigationItemForm.php b/application/forms/Navigation/NavigationItemForm.php index 0efe96d9c9..5029ab5901 100644 --- a/application/forms/Navigation/NavigationItemForm.php +++ b/application/forms/Navigation/NavigationItemForm.php @@ -5,8 +5,11 @@ namespace Icinga\Forms\Navigation; +use Icinga\Util\Csp; use Icinga\Web\Form; use Icinga\Web\Url; +use ipl\Web\Common\CalloutType; +use ipl\Web\Widget\Callout; class NavigationItemForm extends Form { @@ -48,6 +51,24 @@ public function createElements(array $formData) ) ); + if (Csp::isEnabled() && ! Csp::isNavigationEnabled()) { + $this->addElement( + 'note', + 'csp_warning', + [ + 'decorators' => ['ViewHelper'], + 'value' => (new Callout( + CalloutType::Info, + $this->translate( + 'Any external url is not guaranteed to work as expected. ' + . 'Please make sure to check the Content-Security-Policy configuration.' + ), + $this->translate('Navigation items are not enabled in the CSP configuration'), + ))->setFormElement()->render(), + ] + ); + } + $this->addElement( 'textarea', 'url', diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index c556e818b2..5c1690c3d6 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -55,6 +55,10 @@ public static function addHeader(Response $response): void $response->setHeader('Content-Security-Policy', static::getHeader(), true); } + /** + * Check whether sending the CSP header is enabled + * @return bool + */ public static function isEnabled(): bool { return (bool) Config::app()->get('security', 'use_strict_csp', '0'); From 1ca96b2fa51614ae21528d2a8b9483cbcd9c2e47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 13 May 2026 10:42:34 +0200 Subject: [PATCH 81/96] List all users dashboard entries --- .../forms/Config/Security/CspConfigForm.php | 25 +++++---- .../Csp/Loader/DashboardCspLoader.php | 53 +++++++++++++++---- .../Csp/Reason/DashboardCspReason.php | 3 ++ 3 files changed, 62 insertions(+), 19 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index b420b6036c..eca4c4b75b 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -7,9 +7,12 @@ use Exception; use Icinga\Application\Config; +use Icinga\Authentication\Auth; use Icinga\Data\ConfigObject; use Icinga\Security\Csp\LoadedCsp; -use Icinga\Security\Csp\Reason\CspReason; +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; @@ -103,9 +106,9 @@ protected function assemble(): void { Csp::createNonce(); $csps = Csp::load(new ConfigObject([ - 'csp_enable_modules' => '1', - 'csp_enable_dashboards' => '1', - 'csp_enable_navigation' => '1', + 'csp_enable_modules' => '0', + 'csp_enable_dashboards' => '0', + 'csp_enable_navigation' => '0', ])); $this->addElement($this->createUidElement()); @@ -188,7 +191,7 @@ function (StaticCspReason $reason, string $directive, string $expression) { ); $this->addDirectiveContentElement( - $csps, + (new ModuleCspLoader())->load(), [t('Module'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof ModuleCspReason; @@ -207,16 +210,17 @@ function (ModuleCspReason $reason, string $directive, string $expression) { $this->addDirectiveTitleElement( $this->translate('Dashboard'), $this->translate( - 'Enable user defined dashboards. Note: You will only be able to see your own dashboards,' - . ' and there is currently no way to see what others have configured for themselves.' + '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( - $csps, - [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], + (new DashboardCspLoader(true))->load(), + [t('Dashboard'), t('Dashlet'), t('User'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof DashboardCspReason; }, @@ -224,6 +228,7 @@ 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), ]); @@ -243,7 +248,7 @@ function (DashboardCspReason $reason, string $directive, string $expression) { ); $this->addDirectiveContentElement( - $csps, + (new NavigationCspLoader())->load(), [t('Navigation'), t('Parent'), t('Name'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof NavigationCspReason; diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index f7e8aa4fad..0c5ef8adf2 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -5,9 +5,12 @@ namespace Icinga\Security\Csp\Loader; +use DirectoryIterator; +use Icinga\Application\Config; use Icinga\Authentication\Auth; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Reason\DashboardCspReason; +use Icinga\User; use Icinga\Web\Url; use Icinga\Web\Widget\Dashboard; @@ -20,17 +23,20 @@ class DashboardCspLoader implements CspLoader { /** - * Fetches all dashlets for the current user that have an external URL. + * @param bool $allUsers whether to load CSP directives for all users, or only the current user + */ + public function __construct( + protected bool $allUsers = false, + ) { + } + + /** + * @param User $user * - * @return LoadedCsp[] A list of CSP directives, one for each dashlet that has an external URL. + * @return LoadedCsp[] */ - public function load(): array + protected function loadForUser(User $user): array { - $user = Auth::getInstance()->getUser(); - if ($user === null) { - return []; - } - $dashboard = new Dashboard(); $dashboard->setUser($user); $dashboard->load(); @@ -60,7 +66,7 @@ public function load(): array $cspUrl .= ':' . $port; } - $csp = new LoadedCsp(new DashboardCspReason($pane, $dashlet)); + $csp = new LoadedCsp(new DashboardCspReason($dashboard, $pane, $dashlet)); $csp->add('frame-src', $cspUrl); $result[] = $csp; } @@ -68,4 +74,33 @@ public function load(): array return $result; } + + /** + * Fetches all dashlets for the current user that have an external URL. + * + * @return LoadedCsp[] A list of CSP directives, one for each dashlet that has an external URL. + */ + public function load(): array + { + $auth = Auth::getInstance(); + if (! $auth->isAuthenticated()) { + return []; + } + + if ($this->allUsers) { + $csps = []; + foreach (new DirectoryIterator(Config::resolvePath('dashboards')) as $dir) { + if ($dir->isDot() || ! $dir->isDir()) { + continue; + } + + $user = new User($dir->getFilename()); + $csps = array_merge($csps, $this->loadForUser($user)); + } + + return $csps; + } else { + return $this->loadForUser($auth->getUser()); + } + } } diff --git a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php index 62ce62a8fa..ae80f9438f 100644 --- a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php +++ b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php @@ -5,6 +5,7 @@ namespace Icinga\Security\Csp\Reason; +use Icinga\Web\Widget\Dashboard; use Icinga\Web\Widget\Dashboard\Dashlet; use Icinga\Web\Widget\Dashboard\Pane; @@ -15,10 +16,12 @@ 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, ) { From 5d8571e39f195aa056be131ffc9a9dc5ddfed694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Wed, 13 May 2026 13:08:03 +0200 Subject: [PATCH 82/96] List all users navigation items This includes special handling for shared navigation items --- .../forms/Config/Security/CspConfigForm.php | 19 +++- .../Csp/Loader/NavigationCspLoader.php | 88 ++++++++++++++++--- .../Csp/Reason/NavigationCspReason.php | 1 + 3 files changed, 91 insertions(+), 17 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index eca4c4b75b..7d2eb60fab 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -240,16 +240,17 @@ function (DashboardCspReason $reason, string $directive, string $expression) { $this->addDirectiveTitleElement( $this->translate('Navigation'), $this->translate( - 'Enable navigation items. Note: You will only be able to see your own navigation items,' - . ' and there is currently no way to see what others have configured for themselves.' + '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(), - [t('Navigation'), t('Parent'), t('Name'), t('Directive'), t('Value')], + (new NavigationCspLoader(true))->load(), + [t('Navigation'), t('Parent'), t('Name'), t('User'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof NavigationCspReason; }, @@ -264,6 +265,16 @@ function (NavigationCspReason $reason, string $directive, string $expression) { Table::td($reason->typeConfiguration['label'] ?? $reason->type), $parentCell, Table::td($reason->item->getName()), + Table::td( + $reason->username + ?? [ + new Icon('share', [ + 'class' => 'shared-item', + 'title' => t('Shared item. Displayed user is owner.'), + ]), + $reason->item->getAttribute('owner'), + ] + ), Table::td($directive), $this->buildExpression($directive, $expression), ]); diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index 90cab3a0d6..76d321196d 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -5,7 +5,9 @@ namespace Icinga\Security\Csp\Loader; +use DirectoryIterator; use Generator; +use Icinga\Application\Config; use Icinga\Authentication\Auth; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Reason\NavigationCspReason; @@ -18,6 +20,11 @@ */ class NavigationCspLoader implements CspLoader { + function __construct( + protected bool $allUsers = false, + ) { + } + /** * Fetches navigation items for the current user. * @@ -28,27 +35,43 @@ class NavigationCspLoader implements CspLoader */ public function load(): array { - $result = []; - $auth = Auth::getInstance(); if (! $auth->isAuthenticated()) { return []; } + $result = []; $navigationTypes = Navigation::getItemTypeConfiguration(); - foreach ($navigationTypes as $type => $typeConfig) { - $navigation = new Navigation(); - foreach ($navigation->load($type) as $rootItem) { - foreach (self::yieldNavigation($rootItem) as $item) { - $url = $item->getUrl(); - $cspUrl = $url->getScheme() . '://' . $url->getHost(); - if (($port = $url->getPort()) !== null) { - $cspUrl .= ':' . $port; + if ($this->allUsers) { + foreach ($navigationTypes as $type => $typeConfig) { + $sharedConfig = Config::navigation($type); + if (! $sharedConfig->isEmpty()) { + $result = array_merge($result, $this->extractCSPs($sharedConfig, $type, $typeConfig, null)); + } + + foreach (new DirectoryIterator('/etc/icingaweb2/preferences') as $userDir) { + if ($userDir->isDot() || ! $userDir->isDir()) { + continue; } - $csp = new LoadedCsp(new NavigationCspReason($type, $typeConfig, $item)); - $csp->add('frame-src', $cspUrl); - $result[] = $csp; + $config = Config::navigation($type, $userDir->getFilename()); + if ($config->isEmpty()) { + continue; + } + + $result = array_merge( + $result, + $this->extractCSPs($config, $type, $typeConfig, $userDir->getFilename()), + ); + } + } + } else { + foreach ($navigationTypes as $type => $typeConfig) { + $navigation = new Navigation(); + foreach ($navigation->load($type) as $rootItem) { + foreach (self::yieldNavigation($rootItem) as $item) { + $result[] = $this->navItemToCsp($item, $type, $typeConfig, $auth->getUser()->getUsername()); + } } } } @@ -56,6 +79,45 @@ public function load(): array return $result; } + protected function navItemToCsp( + NavigationItem $item, + string $type, + array $typeConfig, + ?string $user + ): LoadedCsp { + $url = $item->getUrl(); + $cspUrl = $url->getScheme() . '://' . $url->getHost(); + if (($port = $url->getPort()) !== null) { + $cspUrl .= ':' . $port; + } + + $csp = new LoadedCsp(new NavigationCspReason($type, $typeConfig, $item, $user)); + $csp->add('frame-src', $cspUrl); + return $csp; + } + + /** + * @param Config $config + * @param string $type + * @param array $typeConfig + * @param string|null $user + * + * @return LoadedCsp[] + */ + protected function extractCSPs(Config $config, string $type, array $typeConfig, ?string $user): array + { + $nav = Navigation::fromConfig($config); + + $result = []; + foreach ($nav as $rootItem) { + foreach (self::yieldNavigation($rootItem) as $item) { + $result[] = $this->navItemToCsp($item, $type, $typeConfig, $user); + } + } + + return $result; + } + /** * Recursively yield all navigation items that have an external URL. * diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index 5ac8576dc1..09eaddfff7 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -22,6 +22,7 @@ public function __construct( public string $type, public array $typeConfiguration, public NavigationItem $item, + public ?string $username = null, ) { } } From 92eb993ee424bc323b86311c02728a70dddadfe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 18 May 2026 10:51:36 +0200 Subject: [PATCH 83/96] Better translations for table headers --- .../forms/Config/Security/CspConfigForm.php | 53 +++++++++---------- 1 file changed, 26 insertions(+), 27 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 7d2eb60fab..abed963ee3 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -7,7 +7,6 @@ use Exception; use Icinga\Application\Config; -use Icinga\Authentication\Auth; use Icinga\Data\ConfigObject; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Loader\DashboardCspLoader; @@ -162,7 +161,12 @@ protected function assemble(): void ), )); - $this->addDirectiveTitleElement($this->translate('System'), 'unused', null, ! $disabledState); + $this->add(HtmlElement::create( + 'h4', + ['class' => "csp-form-hint $disabledClass"], + $this->translate('System'), + )); + $this->addDirectiveContentElement( $csps, [t('Directive'), t('Value')], @@ -180,8 +184,8 @@ function (StaticCspReason $reason, string $directive, string $expression) { $this->translate('No system policies defined.') ); - $this->addDirectiveTitleElement( - $this->translate('Modules'), + $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.' @@ -207,8 +211,8 @@ function (ModuleCspReason $reason, string $directive, string $expression) { $this->translate('No module policies defined.') ); - $this->addDirectiveTitleElement( - $this->translate('Dashboard'), + $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' @@ -237,8 +241,8 @@ function (DashboardCspReason $reason, string $directive, string $expression) { $this->translate('No dashboard policies found.'), ); - $this->addDirectiveTitleElement( - $this->translate('Navigation'), + $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' @@ -382,31 +386,26 @@ public function isCustomCspEnabled(): bool return $this->getPopulatedValue('use_custom_csp') === '1'; } - /** - * @param string $title the title of the section - * @param string|null $description the description of the section - * @param string|null $field the name of the checkbox that controls the section - * @param bool $enabled whether the section should be enabled - * - * @return void - */ - protected function addDirectiveTitleElement( - string $title, - ?string $description, - ?string $field, + protected function addDirectiveCheckboxElement( + string $label, + string $description, + string $field, bool $enabled, ): void { - $disabledClass = $enabled ? '' : 'csp-disabled'; - - if ($field == null) { - $this->add(HtmlElement::create('h4', ['class' => "csp-form-hint $disabledClass"], $title)); - return; + $classList = [ + 'autosubmit', + 'csp-form-content-aligned', + 'csp-label-header-h4', + ]; + + if (! $enabled) { + $classList[] = 'csp-disabled'; } $this->addElement('checkbox', $field, [ - 'label' => sprintf($this->translate('Enable %s'), $title), + 'label' => $label, 'description' => $description, - 'class' => "autosubmit csp-form-content-aligned csp-label-header-h4 $disabledClass", + 'class' => $classList, 'checkedValue' => '1', 'uncheckedValue' => '0', 'disabled' => ! $enabled, From 4b88a49fe8c03410307c6e03eb4bbb6a347adb9c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 18 May 2026 10:26:58 +0200 Subject: [PATCH 84/96] Handle all navigation types and respect share permissions --- .../forms/Config/Security/CspConfigForm.php | 21 +- .../Csp/Loader/NavigationCspLoader.php | 198 +++++++++++------- .../Csp/Reason/NavigationCspReason.php | 11 +- 3 files changed, 144 insertions(+), 86 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index abed963ee3..624b4e32a9 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -259,26 +259,25 @@ function (CspReason $reason) { return $reason instanceof NavigationCspReason; }, function (NavigationCspReason $reason, string $directive, string $expression) { - $parent = $reason->item->getParent(); - if ($parent === null) { + if ($reason->parent === null) { $parentCell = Table::td(t('None'))->setAttribute('class', 'empty-state'); } else { - $parentCell = Table::td($parent->getName()); + $parentCell = Table::td($reason->parent); } return Table::tr([ Table::td($reason->typeConfiguration['label'] ?? $reason->type), $parentCell, - Table::td($reason->item->getName()), - Table::td( - $reason->username - ?? [ - new Icon('share', [ + Table::td($reason->name), + Table::td([ + match ($reason->isShared) { + true => new Icon('share', [ 'class' => 'shared-item', 'title' => t('Shared item. Displayed user is owner.'), ]), - $reason->item->getAttribute('owner'), - ] - ), + false => null, + }, + $reason->username, + ]), Table::td($directive), $this->buildExpression($directive, $expression), ]); diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index 76d321196d..a23618bb28 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -6,13 +6,14 @@ namespace Icinga\Security\Csp\Loader; use DirectoryIterator; -use Generator; use Icinga\Application\Config; use Icinga\Authentication\Auth; +use Icinga\Data\ConfigObject; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Reason\NavigationCspReason; +use Icinga\User; use Icinga\Web\Navigation\Navigation; -use Icinga\Web\Navigation\NavigationItem; +use ipl\Web\Url; /** * Loads CSP directives for navigation items that have an external URL. @@ -20,13 +21,83 @@ */ class NavigationCspLoader implements CspLoader { - function __construct( + public function __construct( protected bool $allUsers = false, ) { } /** - * Fetches navigation items for the current user. + * 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 LoadedCsp[] + */ + 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 LoadedCsp(new NavigationCspReason( + $type, + $typeConfig, + $parent, + $sectionName, + $isShared, + $username ?? $owner, + )); + $csp->add('frame-src', $cspUrl); + $result[] = $csp; + } + + return $result; + } + + /** + * Fetches navigation items for the current user * * Iterates through all registered navigation types, loads both user-specific * and shared configurations, and returns a list of menu items. @@ -44,100 +115,83 @@ public function load(): array $navigationTypes = Navigation::getItemTypeConfiguration(); if ($this->allUsers) { foreach ($navigationTypes as $type => $typeConfig) { - $sharedConfig = Config::navigation($type); - if (! $sharedConfig->isEmpty()) { - $result = array_merge($result, $this->extractCSPs($sharedConfig, $type, $typeConfig, null)); - } + $result = array_merge($result, $this->loadConfig($type, $typeConfig)); - foreach (new DirectoryIterator('/etc/icingaweb2/preferences') as $userDir) { + foreach (new DirectoryIterator(Config::resolvePath('preferences')) as $userDir) { if ($userDir->isDot() || ! $userDir->isDir()) { continue; } - $config = Config::navigation($type, $userDir->getFilename()); - if ($config->isEmpty()) { - continue; - } - - $result = array_merge( - $result, - $this->extractCSPs($config, $type, $typeConfig, $userDir->getFilename()), - ); + $result = array_merge($result, $this->loadConfig($type, $typeConfig, $userDir->getFilename())); } } } else { + $username = $auth->getUser()->getUsername(); foreach ($navigationTypes as $type => $typeConfig) { - $navigation = new Navigation(); - foreach ($navigation->load($type) as $rootItem) { - foreach (self::yieldNavigation($rootItem) as $item) { - $result[] = $this->navItemToCsp($item, $type, $typeConfig, $auth->getUser()->getUsername()); - } - } + $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; } - protected function navItemToCsp( - NavigationItem $item, - string $type, - array $typeConfig, - ?string $user - ): LoadedCsp { - $url = $item->getUrl(); - $cspUrl = $url->getScheme() . '://' . $url->getHost(); - if (($port = $url->getPort()) !== null) { - $cspUrl .= ':' . $port; - } - - $csp = new LoadedCsp(new NavigationCspReason($type, $typeConfig, $item, $user)); - $csp->add('frame-src', $cspUrl); - return $csp; - } - /** - * @param Config $config - * @param string $type - * @param array $typeConfig - * @param string|null $user + * Checks whether the user has access to a shared navigation item * - * @return LoadedCsp[] + * Also handles inheritance of access restrictions. + * Note: 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 */ - protected function extractCSPs(Config $config, string $type, array $typeConfig, ?string $user): array + private function hasAccessToSharedNavigationItem(ConfigObject $config, Config $navConfig, User $user): bool { - $nav = Navigation::fromConfig($config); + if (isset($config['owner']) && strtolower($config['owner']) === strtolower($user->getUsername())) { + return true; + } - $result = []; - foreach ($nav as $rootItem) { - foreach (self::yieldNavigation($rootItem) as $item) { - $result[] = $this->navItemToCsp($item, $type, $typeConfig, $user); + 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; } } - return $result; - } + if (isset($config['groups'])) { + $groups = array_map(trim(...), explode(',', strtolower($config['groups']))); + if (in_array('*', $groups, true)) { + return true; + } - /** - * Recursively yield all navigation items that have an external URL. - * - * @param NavigationItem $item The top-level navigation item to start from. - * @return Generator - */ - protected static function yieldNavigation(NavigationItem $item): Generator - { - if ($item->hasChildren()) { - foreach ($item as $child) { - yield from self::yieldNavigation($child); + $userGroups = array_map(strtolower(...), $user->getGroups()); + $matches = array_intersect($userGroups, $groups); + if (! empty($matches)) { + return true; } } - $url = $item->getUrl(); - if ($url === null) { - return; - } - if ($item->getTarget() !== '_blank' && $url->isExternal()) { - yield $item; - } + return false; } } diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index 09eaddfff7..a2e10f62dd 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -16,13 +16,18 @@ /** * @param string $type the type of the navigation item * @param array $typeConfiguration the configuration of the navigation item type - * @param NavigationItem $item the navigation item to load the CSP directive for + * @param string|null $parent + * @param string $name + * @param bool $isShared + * @param string $username */ public function __construct( public string $type, public array $typeConfiguration, - public NavigationItem $item, - public ?string $username = null, + public ?string $parent, + public string $name, + public bool $isShared, + public string $username, ) { } } From 49b956211045f90e09dea98a17197cabf712334c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 18 May 2026 13:25:42 +0200 Subject: [PATCH 85/96] Refactor LoadedCsp to no longer extend Csp --- .../forms/Config/Security/CspConfigForm.php | 36 ++------- library/Icinga/Security/Csp/LoadedCsp.php | 18 +---- .../Csp/Loader/DashboardCspLoader.php | 11 +-- .../Security/Csp/Loader/ModuleCspLoader.php | 2 +- .../Csp/Loader/NavigationCspLoader.php | 7 +- .../Security/Csp/Loader/StaticCspLoader.php | 5 +- library/Icinga/Util/Csp.php | 79 ++++++++----------- 7 files changed, 59 insertions(+), 99 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 624b4e32a9..0b60e281cd 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -7,7 +7,6 @@ use Exception; use Icinga\Application\Config; -use Icinga\Data\ConfigObject; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Loader\DashboardCspLoader; use Icinga\Security\Csp\Loader\ModuleCspLoader; @@ -104,11 +103,6 @@ public function __construct(protected Config $config) protected function assemble(): void { Csp::createNonce(); - $csps = Csp::load(new ConfigObject([ - 'csp_enable_modules' => '0', - 'csp_enable_dashboards' => '0', - 'csp_enable_navigation' => '0', - ])); $this->addElement($this->createUidElement()); @@ -168,12 +162,8 @@ protected function assemble(): void )); $this->addDirectiveContentElement( - $csps, + [Csp::getSystemCsp()], [t('Directive'), t('Value')], - function (CspReason $reason) { - return $reason instanceof StaticCspReason - && $reason->name === 'system'; - }, function (StaticCspReason $reason, string $directive, string $expression) { return Table::tr([ Table::td($directive), @@ -197,9 +187,6 @@ function (StaticCspReason $reason, string $directive, string $expression) { $this->addDirectiveContentElement( (new ModuleCspLoader())->load(), [t('Module'), t('Directive'), t('Value')], - function (CspReason $reason) { - return $reason instanceof ModuleCspReason; - }, function (ModuleCspReason $reason, string $directive, string $expression) { return Table::tr([ Table::td($reason->module), @@ -225,9 +212,6 @@ function (ModuleCspReason $reason, string $directive, string $expression) { $this->addDirectiveContentElement( (new DashboardCspLoader(true))->load(), [t('Dashboard'), t('Dashlet'), t('User'), t('Directive'), t('Value')], - function (CspReason $reason) { - return $reason instanceof DashboardCspReason; - }, function (DashboardCspReason $reason, string $directive, string $expression) { return Table::tr([ Table::td($reason->pane->getName()), @@ -255,9 +239,6 @@ function (DashboardCspReason $reason, string $directive, string $expression) { $this->addDirectiveContentElement( (new NavigationCspLoader(true))->load(), [t('Navigation'), t('Parent'), t('Name'), t('User'), t('Directive'), t('Value')], - function (CspReason $reason) { - return $reason instanceof NavigationCspReason; - }, function (NavigationCspReason $reason, string $directive, string $expression) { if ($reason->parent === null) { $parentCell = Table::td(t('None'))->setAttribute('class', 'empty-state'); @@ -413,9 +394,8 @@ protected function addDirectiveCheckboxElement( } /** - * @param LoadedCsp[] $csps the list of cps along with their reasons + * @param LoadedCsp[] $loadedCsps the list of cps along with their reasons * @param string[] $header the header of the table - * @param callable $filter a filter function that returns true if the csp should be included in the table * @param callable $rowBuilder a function that builds a row for the table * @param bool $enabled whether the content should be enabled * @param string $emptyText the text to display if there are no policies @@ -423,21 +403,17 @@ protected function addDirectiveCheckboxElement( * @return void */ protected function addDirectiveContentElement( - array $csps, + array $loadedCsps, array $header, - callable $filter, callable $rowBuilder, bool $enabled, string $emptyText, ): void { $rows = []; - foreach ($csps as $csp) { - if (! $filter($csp->loadReason)) { - continue; - } - foreach ($csp->getDirectives() as $directive => $expressions) { + foreach ($loadedCsps as $loaded) { + foreach ($loaded->csp->getDirectives() as $directive => $expressions) { foreach ($expressions as $expression) { - $rows[] = $rowBuilder($csp->loadReason, $directive, $expression); + $rows[] = $rowBuilder($loaded->reason, $directive, $expression); } } } diff --git a/library/Icinga/Security/Csp/LoadedCsp.php b/library/Icinga/Security/Csp/LoadedCsp.php index c9ba26cc80..871bb0d3d2 100644 --- a/library/Icinga/Security/Csp/LoadedCsp.php +++ b/library/Icinga/Security/Csp/LoadedCsp.php @@ -9,23 +9,13 @@ use ipl\Web\Common\Csp; /** - * A CSP that has been loaded from a source. - * Contains the reason for the CSP directive/policy to exist. + * Wrapper class for CSP directives that have been loaded by a {@see CspLoader} */ -class LoadedCsp extends Csp +class LoadedCsp { - /** - * @param CspReason $loadReason the reason for the CSP directive/policy to exist - */ public function __construct( - public readonly CspReason $loadReason, + public readonly Csp $csp, + public readonly CspReason $reason, ) { } - - public static function fromCsp(Csp $csp, CspReason $reason): static - { - $instance = new static($reason); - $instance->directives = $csp->directives; - return $instance; - } } diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index 0c5ef8adf2..ffabb5374f 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -13,6 +13,7 @@ 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. @@ -66,9 +67,9 @@ protected function loadForUser(User $user): array $cspUrl .= ':' . $port; } - $csp = new LoadedCsp(new DashboardCspReason($dashboard, $pane, $dashlet)); + $csp = new Csp(); $csp->add('frame-src', $cspUrl); - $result[] = $csp; + $result[] = new LoadedCsp($csp, new DashboardCspReason($dashboard, $pane, $dashlet)); } } @@ -88,17 +89,17 @@ public function load(): array } if ($this->allUsers) { - $csps = []; + $result = []; foreach (new DirectoryIterator(Config::resolvePath('dashboards')) as $dir) { if ($dir->isDot() || ! $dir->isDir()) { continue; } $user = new User($dir->getFilename()); - $csps = array_merge($csps, $this->loadForUser($user)); + $result = array_merge($result, $this->loadForUser($user)); } - return $csps; + return $result; } else { return $this->loadForUser($auth->getUser()); } diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index f4c9cb2f25..7531a72a74 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -35,7 +35,7 @@ public function load(): array if ($csp->isEmpty()) { continue; } - $result[] = LoadedCsp::fromCsp( + $result[] = new LoadedCsp( $csp, new ModuleCspReason(ClassLoader::extractModuleName(get_class($hook))), ); diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index a23618bb28..208e270d0b 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -13,6 +13,7 @@ use Icinga\Security\Csp\Reason\NavigationCspReason; use Icinga\User; use Icinga\Web\Navigation\Navigation; +use ipl\Web\Common\Csp; use ipl\Web\Url; /** @@ -81,7 +82,9 @@ protected function loadConfig( $parent = $section->get('parent'); $isShared = $username === null; - $csp = new LoadedCsp(new NavigationCspReason( + $csp = new Csp(); + $csp->add('frame-src', $cspUrl); + $result[] = new LoadedCsp($csp, new NavigationCspReason( $type, $typeConfig, $parent, @@ -89,8 +92,6 @@ protected function loadConfig( $isShared, $username ?? $owner, )); - $csp->add('frame-src', $cspUrl); - $result[] = $csp; } return $result; diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php index 6146ec4db3..f94a258cf2 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -7,6 +7,7 @@ use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Reason\StaticCspReason; +use ipl\Web\Common\Csp; /** * Loads CSP directives from a static array. @@ -27,11 +28,11 @@ public function __construct( public function load(): array { - $csp = new LoadedCsp(new StaticCspReason($this->name)); + $csp = new Csp(); foreach ($this->directives as $directive => $values) { $csp->add($directive, $values); } - return [$csp]; + return [new LoadedCsp($csp, new StaticCspReason($this->name))]; } } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 5c1690c3d6..77b382a04e 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -9,7 +9,6 @@ use Icinga\Application\Config; use Icinga\Application\Icinga; use Icinga\Application\Logger; -use Icinga\Data\ConfigObject; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Loader\DashboardCspLoader; use Icinga\Security\Csp\Loader\ModuleCspLoader; @@ -33,7 +32,7 @@ */ class Csp { - /** @var CspInstance|null */ + /** @var ?CspInstance */ protected static ?CspInstance $csp = null; /** Singleton */ @@ -125,58 +124,23 @@ public static function isNavigationEnabled(): bool return (bool) Config::app()->get('security', 'csp_enable_navigation', '1'); } - /** - * Load configured CSP policies - * @return LoadedCsp[] - */ - public static function load(?ConfigObject $config = null): array + public static function getSystemCsp(): LoadedCsp { - if ($config === null) { - $config = Config::app()->getSection('security'); - } - $nonce = static::getStyleNonce(); if (empty($nonce)) { throw new RuntimeException('No nonce set for CSS'); } - $result = []; - $result = array_merge($result, (new StaticCspLoader( + 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}'"], + 'style-src' => ["'self'", "'nonce-$nonce'"], 'font-src' => ["'self'", "data:"], 'img-src' => ["'self'", "data:"], 'frame-src' => ["'self'"], - ] - ))->load()); - - try { - if ($config->get('csp_enable_modules', '1')) { - $result = array_merge($result, (new ModuleCspLoader())->load()); - } - } catch (Exception $e) { - Logger::warning('Module CSP loader failed: %s', $e->getMessage()); - } - - try { - if ($config->get('csp_enable_dashboards', '1')) { - $result = array_merge($result, (new DashboardCspLoader())->load()); - } - } catch (Exception $e) { - Logger::warning('Dashboard CSP loader failed: %s', $e->getMessage()); - } - - try { - if ($config->get('csp_enable_navigation', '1')) { - $result = array_merge($result, (new NavigationCspLoader())->load()); - } - } catch (Exception $e) { - Logger::warning('Navigation CSP loader failed: %s', $e->getMessage()); - } - - return $result; + ], + ))->load()[0]; } /** @@ -214,7 +178,7 @@ protected static function getCustomHeader(): CspInstance $config = Config::app(); $customCsp = $config->get('security', 'custom_csp', ''); - $customCsp = str_replace('{style_nonce}', "'nonce-{$nonce}'", $customCsp); + $customCsp = str_replace('{style_nonce}', "'nonce-$nonce'", $customCsp); return CspInstance::fromString($customCsp); } @@ -228,7 +192,34 @@ protected static function getCustomHeader(): CspInstance */ protected static function getAutomaticHeader(): CspInstance { - $csps = self::load(); + $loadedCsps = [static::getSystemCsp()]; + + try { + if (Csp::isModuleEnabled()) { + $loadedCsps = array_merge($loadedCsps, (new ModuleCspLoader())->load()); + } + } catch (Exception $e) { + Logger::warning('Module CSP loader failed: %s', $e->getMessage()); + } + + try { + if (Csp::isDashboardEnabled()) { + $loadedCsps = array_merge($loadedCsps, (new DashboardCspLoader())->load()); + } + } catch (Exception $e) { + Logger::warning('Dashboard CSP loader failed: %s', $e->getMessage()); + } + + try { + if (Csp::isNavigationEnabled()) { + $loadedCsps = array_merge($loadedCsps, (new NavigationCspLoader())->load()); + } + } catch (Exception $e) { + Logger::warning('Navigation CSP loader failed: %s', $e->getMessage()); + } + + $csps = array_map(fn (LoadedCsp $csp) => $csp->csp, $loadedCsps); + return CspInstance::merge(...$csps); } From f88cffbc17a0171f118f0feebfaebde32edb80df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 11:27:41 +0200 Subject: [PATCH 86/96] Check if the directory exists before iterating --- library/Icinga/Security/Csp/Loader/DashboardCspLoader.php | 7 +++++-- .../Icinga/Security/Csp/Loader/NavigationCspLoader.php | 8 +++++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index ffabb5374f..3d2461ed17 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -90,11 +90,14 @@ public function load(): array if ($this->allUsers) { $result = []; - foreach (new DirectoryIterator(Config::resolvePath('dashboards')) as $dir) { + $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)); } diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index 208e270d0b..a82c9e2cf0 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -117,12 +117,14 @@ public function load(): array if ($this->allUsers) { foreach ($navigationTypes as $type => $typeConfig) { $result = array_merge($result, $this->loadConfig($type, $typeConfig)); - - foreach (new DirectoryIterator(Config::resolvePath('preferences')) as $userDir) { + $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())); } } From 2e1754d909ff0dc58e6eb6d9c05a6004c67d6f3a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 18 May 2026 13:36:25 +0200 Subject: [PATCH 87/96] Rename LoadedCsp to AttributedCsp --- .../forms/Config/Security/CspConfigForm.php | 12 ++++++------ .../Csp/{LoadedCsp.php => AttributedCsp.php} | 8 ++++---- library/Icinga/Security/Csp/Loader/CspLoader.php | 4 ++-- .../Security/Csp/Loader/DashboardCspLoader.php | 8 ++++---- .../Icinga/Security/Csp/Loader/ModuleCspLoader.php | 6 +++--- .../Security/Csp/Loader/NavigationCspLoader.php | 8 ++++---- .../Icinga/Security/Csp/Loader/StaticCspLoader.php | 4 ++-- library/Icinga/Util/Csp.php | 14 +++++++------- 8 files changed, 32 insertions(+), 32 deletions(-) rename library/Icinga/Security/Csp/{LoadedCsp.php => AttributedCsp.php} (61%) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 0b60e281cd..58e453bdbf 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -7,7 +7,7 @@ use Exception; use Icinga\Application\Config; -use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\AttributedCsp; use Icinga\Security\Csp\Loader\DashboardCspLoader; use Icinga\Security\Csp\Loader\ModuleCspLoader; use Icinga\Security\Csp\Loader\NavigationCspLoader; @@ -394,7 +394,7 @@ protected function addDirectiveCheckboxElement( } /** - * @param LoadedCsp[] $loadedCsps the list of cps along with their reasons + * @param AttributedCsp[] $attributedCsps the list of cps 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 @@ -403,17 +403,17 @@ protected function addDirectiveCheckboxElement( * @return void */ protected function addDirectiveContentElement( - array $loadedCsps, + array $attributedCsps, array $header, callable $rowBuilder, bool $enabled, string $emptyText, ): void { $rows = []; - foreach ($loadedCsps as $loaded) { - foreach ($loaded->csp->getDirectives() as $directive => $expressions) { + foreach ($attributedCsps as $attributed) { + foreach ($attributed->csp->getDirectives() as $directive => $expressions) { foreach ($expressions as $expression) { - $rows[] = $rowBuilder($loaded->reason, $directive, $expression); + $rows[] = $rowBuilder($attributed->reason, $directive, $expression); } } } diff --git a/library/Icinga/Security/Csp/LoadedCsp.php b/library/Icinga/Security/Csp/AttributedCsp.php similarity index 61% rename from library/Icinga/Security/Csp/LoadedCsp.php rename to library/Icinga/Security/Csp/AttributedCsp.php index 871bb0d3d2..f1db6dca5f 100644 --- a/library/Icinga/Security/Csp/LoadedCsp.php +++ b/library/Icinga/Security/Csp/AttributedCsp.php @@ -9,13 +9,13 @@ use ipl\Web\Common\Csp; /** - * Wrapper class for CSP directives that have been loaded by a {@see CspLoader} + * A CSP directive attributed to a specific source via a {@see CspReason} */ -class LoadedCsp +readonly class AttributedCsp { public function __construct( - public readonly Csp $csp, - public readonly CspReason $reason, + public Csp $csp, + public CspReason $reason, ) { } } diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php index 6fe3f6bdbb..3d75a2ef8e 100644 --- a/library/Icinga/Security/Csp/Loader/CspLoader.php +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -5,7 +5,7 @@ namespace Icinga\Security\Csp\Loader; -use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\AttributedCsp; /** * Interface for CSP loaders. @@ -16,7 +16,7 @@ interface CspLoader /** * Load the CSP directives from the source. * - * @return LoadedCsp[] + * @return AttributedCsp[] */ public function load(): array; } diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index 3d2461ed17..96045005f4 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -8,7 +8,7 @@ use DirectoryIterator; use Icinga\Application\Config; use Icinga\Authentication\Auth; -use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\AttributedCsp; use Icinga\Security\Csp\Reason\DashboardCspReason; use Icinga\User; use Icinga\Web\Url; @@ -34,7 +34,7 @@ public function __construct( /** * @param User $user * - * @return LoadedCsp[] + * @return AttributedCsp[] */ protected function loadForUser(User $user): array { @@ -69,7 +69,7 @@ protected function loadForUser(User $user): array $csp = new Csp(); $csp->add('frame-src', $cspUrl); - $result[] = new LoadedCsp($csp, new DashboardCspReason($dashboard, $pane, $dashlet)); + $result[] = new AttributedCsp($csp, new DashboardCspReason($dashboard, $pane, $dashlet)); } } @@ -79,7 +79,7 @@ protected function loadForUser(User $user): array /** * Fetches all dashlets for the current user that have an external URL. * - * @return LoadedCsp[] A list of CSP directives, one for each dashlet that has an external URL. + * @return AttributedCsp[] A list of CSP directives, one for each dashlet that has an external URL. */ public function load(): array { diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index 7531a72a74..acc4481f1e 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -8,7 +8,7 @@ use Icinga\Application\ClassLoader; use Icinga\Application\Hook\CspHook; use Icinga\Application\Logger; -use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\AttributedCsp; use Icinga\Security\Csp\Reason\ModuleCspReason; use Throwable; @@ -23,7 +23,7 @@ class ModuleCspLoader implements CspLoader * List all CSP directives from modules. * See {@see CspHook} for details. * - * @return LoadedCsp[] + * @return AttributedCsp[] */ public function load(): array { @@ -35,7 +35,7 @@ public function load(): array if ($csp->isEmpty()) { continue; } - $result[] = new LoadedCsp( + $result[] = new AttributedCsp( $csp, new ModuleCspReason(ClassLoader::extractModuleName(get_class($hook))), ); diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index a82c9e2cf0..632b404c6e 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -9,7 +9,7 @@ use Icinga\Application\Config; use Icinga\Authentication\Auth; use Icinga\Data\ConfigObject; -use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\AttributedCsp; use Icinga\Security\Csp\Reason\NavigationCspReason; use Icinga\User; use Icinga\Web\Navigation\Navigation; @@ -37,7 +37,7 @@ public function __construct( * @param ?User $currentUser The optional user to check access for. * If provided, access restrictions are checked. * - * @return LoadedCsp[] + * @return AttributedCsp[] */ protected function loadConfig( string $type, @@ -84,7 +84,7 @@ protected function loadConfig( $csp = new Csp(); $csp->add('frame-src', $cspUrl); - $result[] = new LoadedCsp($csp, new NavigationCspReason( + $result[] = new AttributedCsp($csp, new NavigationCspReason( $type, $typeConfig, $parent, @@ -103,7 +103,7 @@ protected function loadConfig( * Iterates through all registered navigation types, loads both user-specific * and shared configurations, and returns a list of menu items. * - * @return LoadedCsp[] A list of CSP directives, one for each navigation-item that has an external URL. + * @return AttributedCsp[] A list of CSP directives, one for each navigation-item that has an external URL. */ public function load(): array { diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php index f94a258cf2..b6f0fe1242 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -5,7 +5,7 @@ namespace Icinga\Security\Csp\Loader; -use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\AttributedCsp; use Icinga\Security\Csp\Reason\StaticCspReason; use ipl\Web\Common\Csp; @@ -33,6 +33,6 @@ public function load(): array $csp->add($directive, $values); } - return [new LoadedCsp($csp, new StaticCspReason($this->name))]; + return [new AttributedCsp($csp, new StaticCspReason($this->name))]; } } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 77b382a04e..c8ed11b12e 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -9,7 +9,7 @@ use Icinga\Application\Config; use Icinga\Application\Icinga; use Icinga\Application\Logger; -use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\AttributedCsp; use Icinga\Security\Csp\Loader\DashboardCspLoader; use Icinga\Security\Csp\Loader\ModuleCspLoader; use Icinga\Security\Csp\Loader\NavigationCspLoader; @@ -124,7 +124,7 @@ public static function isNavigationEnabled(): bool return (bool) Config::app()->get('security', 'csp_enable_navigation', '1'); } - public static function getSystemCsp(): LoadedCsp + public static function getSystemCsp(): AttributedCsp { $nonce = static::getStyleNonce(); if (empty($nonce)) { @@ -192,11 +192,11 @@ protected static function getCustomHeader(): CspInstance */ protected static function getAutomaticHeader(): CspInstance { - $loadedCsps = [static::getSystemCsp()]; + $attributedCsps = [static::getSystemCsp()]; try { if (Csp::isModuleEnabled()) { - $loadedCsps = array_merge($loadedCsps, (new ModuleCspLoader())->load()); + $attributedCsps = array_merge($attributedCsps, (new ModuleCspLoader())->load()); } } catch (Exception $e) { Logger::warning('Module CSP loader failed: %s', $e->getMessage()); @@ -204,7 +204,7 @@ protected static function getAutomaticHeader(): CspInstance try { if (Csp::isDashboardEnabled()) { - $loadedCsps = array_merge($loadedCsps, (new DashboardCspLoader())->load()); + $attributedCsps = array_merge($attributedCsps, (new DashboardCspLoader())->load()); } } catch (Exception $e) { Logger::warning('Dashboard CSP loader failed: %s', $e->getMessage()); @@ -212,13 +212,13 @@ protected static function getAutomaticHeader(): CspInstance try { if (Csp::isNavigationEnabled()) { - $loadedCsps = array_merge($loadedCsps, (new NavigationCspLoader())->load()); + $attributedCsps = array_merge($attributedCsps, (new NavigationCspLoader())->load()); } } catch (Exception $e) { Logger::warning('Navigation CSP loader failed: %s', $e->getMessage()); } - $csps = array_map(fn (LoadedCsp $csp) => $csp->csp, $loadedCsps); + $csps = array_map(fn (AttributedCsp $csp) => $csp->csp, $attributedCsps); return CspInstance::merge(...$csps); } From 1b33e08911b3af8c0aa13f8db4148a647f1890d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 08:55:10 +0200 Subject: [PATCH 88/96] Move the allUsers flag to the load method This commit also introduces the allUsers flag to the CspHook --- .../forms/Config/Security/CspConfigForm.php | 6 +++--- doc/60-Hooks.md | 2 +- library/Icinga/Application/Hook/CspHook.php | 5 ++++- .../Icinga/Security/Csp/Loader/CspLoader.php | 7 +++++-- .../Csp/Loader/DashboardCspLoader.php | 17 ++------------- .../Security/Csp/Loader/ModuleCspLoader.php | 10 ++------- .../Csp/Loader/NavigationCspLoader.php | 21 ++++--------------- .../Security/Csp/Loader/StaticCspLoader.php | 2 +- 8 files changed, 22 insertions(+), 48 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 58e453bdbf..de8f335a4c 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -185,7 +185,7 @@ function (StaticCspReason $reason, string $directive, string $expression) { ); $this->addDirectiveContentElement( - (new ModuleCspLoader())->load(), + (new ModuleCspLoader())->load(true), [t('Module'), t('Directive'), t('Value')], function (ModuleCspReason $reason, string $directive, string $expression) { return Table::tr([ @@ -210,7 +210,7 @@ function (ModuleCspReason $reason, string $directive, string $expression) { ); $this->addDirectiveContentElement( - (new DashboardCspLoader(true))->load(), + (new DashboardCspLoader())->load(true), [t('Dashboard'), t('Dashlet'), t('User'), t('Directive'), t('Value')], function (DashboardCspReason $reason, string $directive, string $expression) { return Table::tr([ @@ -237,7 +237,7 @@ function (DashboardCspReason $reason, string $directive, string $expression) { ); $this->addDirectiveContentElement( - (new NavigationCspLoader(true))->load(), + (new NavigationCspLoader())->load(true), [t('Navigation'), t('Parent'), t('Name'), t('User'), t('Directive'), t('Value')], function (NavigationCspReason $reason, string $directive, string $expression) { if ($reason->parent === null) { diff --git a/doc/60-Hooks.md b/doc/60-Hooks.md index f1e7ef0504..14581178b5 100644 --- a/doc/60-Hooks.md +++ b/doc/60-Hooks.md @@ -65,7 +65,7 @@ use ipl\Web\Common\Csp as CspInstance; class Csp extends CspHook { - public function getCsp(): CspInstance + public function getCsp(bool $allUsers): CspInstance { $csp = new CspInstance(); $csp->add('img-src', ['cdn.example.com', 'usercontent.example.com']); diff --git a/library/Icinga/Application/Hook/CspHook.php b/library/Icinga/Application/Hook/CspHook.php index 269b3a5f92..3ee9f1f1e8 100644 --- a/library/Icinga/Application/Hook/CspHook.php +++ b/library/Icinga/Application/Hook/CspHook.php @@ -18,9 +18,12 @@ abstract class CspHook * Allow the module to provide custom directives and policies for the CSP header. * The return value should be an instance of Csp with the requested policies. * + * @param bool $allUsers Whether the Csp should contain directives for all users + * or only for the currently authenticated user. + * * @return Csp a CSP instance, this instance will be merged with all other requested directives. */ - abstract public function getCsp(): Csp; + abstract public function getCsp(bool $allUsers): Csp; /** * Get all registered implementations diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php index 3d75a2ef8e..8d1838dcc2 100644 --- a/library/Icinga/Security/Csp/Loader/CspLoader.php +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -14,9 +14,12 @@ interface CspLoader { /** - * Load the CSP directives from the source. + * 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(): array; + public function load(bool $allUsers = false): array; } diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index 96045005f4..863d78b1ad 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -23,14 +23,6 @@ */ class DashboardCspLoader implements CspLoader { - /** - * @param bool $allUsers whether to load CSP directives for all users, or only the current user - */ - public function __construct( - protected bool $allUsers = false, - ) { - } - /** * @param User $user * @@ -76,19 +68,14 @@ protected function loadForUser(User $user): array return $result; } - /** - * Fetches all dashlets for the current user that have an external URL. - * - * @return AttributedCsp[] A list of CSP directives, one for each dashlet that has an external URL. - */ - public function load(): array + public function load(bool $allUsers = false): array { $auth = Auth::getInstance(); if (! $auth->isAuthenticated()) { return []; } - if ($this->allUsers) { + if ($allUsers) { $result = []; $dashboardsDir = Config::resolvePath('dashboards'); if (! is_dir($dashboardsDir)) { diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index acc4481f1e..9cf092f7c3 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -19,19 +19,13 @@ */ class ModuleCspLoader implements CspLoader { - /** - * List all CSP directives from modules. - * See {@see CspHook} for details. - * - * @return AttributedCsp[] - */ - public function load(): array + public function load(bool $allUsers = false): array { $result = []; foreach (CspHook::all() as $hook) { try { - $csp = $hook->getCsp(); + $csp = $hook->getCsp($allUsers); if ($csp->isEmpty()) { continue; } diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index 632b404c6e..a529b4ee48 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -22,11 +22,6 @@ */ class NavigationCspLoader implements CspLoader { - public function __construct( - protected bool $allUsers = false, - ) { - } - /** * Loads CSP directives for navigation items that have an external URL * @@ -97,15 +92,7 @@ protected function loadConfig( return $result; } - /** - * Fetches navigation items for the current user - * - * Iterates through all registered navigation types, loads both user-specific - * and shared configurations, and returns a list of menu items. - * - * @return AttributedCsp[] A list of CSP directives, one for each navigation-item that has an external URL. - */ - public function load(): array + public function load(bool $allUsers = false): array { $auth = Auth::getInstance(); if (! $auth->isAuthenticated()) { @@ -114,7 +101,7 @@ public function load(): array $result = []; $navigationTypes = Navigation::getItemTypeConfiguration(); - if ($this->allUsers) { + if ($allUsers) { foreach ($navigationTypes as $type => $typeConfig) { $result = array_merge($result, $this->loadConfig($type, $typeConfig)); $preferencesDir = Config::resolvePath('preferences'); @@ -151,8 +138,8 @@ public function load(): array /** * Checks whether the user has access to a shared navigation item * - * Also handles inheritance of access restrictions. - * Note: This method mimics the behavior of {@see \Icinga\Application\Web::hasAccessToSharedNavigationItem()}. + * 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 diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php index b6f0fe1242..0b1359afc3 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -26,7 +26,7 @@ public function __construct( ) { } - public function load(): array + public function load(bool $allUsers = false): array { $csp = new Csp(); foreach ($this->directives as $directive => $values) { From 732d1eb6908e6b93f14992d0e6ab60880f484fed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 09:41:37 +0200 Subject: [PATCH 89/96] Use $this->translate instead of t() --- .../forms/Config/Security/CspConfigForm.php | 41 ++++++++++++------- 1 file changed, 27 insertions(+), 14 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index de8f335a4c..3e130ea85e 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -163,7 +163,7 @@ protected function assemble(): void $this->addDirectiveContentElement( [Csp::getSystemCsp()], - [t('Directive'), t('Value')], + [$this->translate('Directive'), $this->translate('Value')], function (StaticCspReason $reason, string $directive, string $expression) { return Table::tr([ Table::td($directive), @@ -186,7 +186,7 @@ function (StaticCspReason $reason, string $directive, string $expression) { $this->addDirectiveContentElement( (new ModuleCspLoader())->load(true), - [t('Module'), t('Directive'), t('Value')], + [$this->translate('Module'), $this->translate('Directive'), $this->translate('Value')], function (ModuleCspReason $reason, string $directive, string $expression) { return Table::tr([ Table::td($reason->module), @@ -211,7 +211,13 @@ function (ModuleCspReason $reason, string $directive, string $expression) { $this->addDirectiveContentElement( (new DashboardCspLoader())->load(true), - [t('Dashboard'), t('Dashlet'), t('User'), t('Directive'), t('Value')], + [ + $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()), @@ -238,10 +244,17 @@ function (DashboardCspReason $reason, string $directive, string $expression) { $this->addDirectiveContentElement( (new NavigationCspLoader())->load(true), - [t('Navigation'), t('Parent'), t('Name'), t('User'), t('Directive'), t('Value')], + [ + $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(t('None'))->setAttribute('class', 'empty-state'); + $parentCell = Table::td($this->translate('None'))->setAttribute('class', 'empty-state'); } else { $parentCell = Table::td($reason->parent); } @@ -253,7 +266,7 @@ function (NavigationCspReason $reason, string $directive, string $expression) { match ($reason->isShared) { true => new Icon('share', [ 'class' => 'shared-item', - 'title' => t('Shared item. Displayed user is owner.'), + 'title' => $this->translate('Shared item. Displayed user is owner.'), ]), false => null, }, @@ -294,7 +307,7 @@ function (NavigationCspReason $reason, string $directive, string $expression) { } $this->addElement('textarea', 'custom_csp', [ - 'label' => $this->translate(''), + '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.', @@ -322,7 +335,7 @@ function (NavigationCspReason $reason, string $directive, string $expression) { } $this->addElement('submit', 'submit', [ - 'label' => t('Save changes'), + 'label' => $this->translate('Save changes'), ]); } @@ -363,7 +376,7 @@ public function isCspEnabled(): bool public function isCustomCspEnabled(): bool { - return $this->getPopulatedValue('use_custom_csp') === '1'; + return $this->getValue('use_custom_csp') === '1'; } protected function addDirectiveCheckboxElement( @@ -508,7 +521,7 @@ protected function buildExpression(string $directive, string $expression): BaseH 'warning', [ 'class' => 'csp-expression-info', - 'title' => t( + 'title' => $this->translate( 'This is a wildcard expression. It allows everything and should therefore be avoided.' ), ] @@ -521,7 +534,7 @@ protected function buildExpression(string $directive, string $expression): BaseH 'warning', [ 'class' => 'csp-expression-info', - 'title' => t('This is a potentially unsafe keyword.'), + 'title' => $this->translate('This is a potentially unsafe keyword.'), ] ), default => null, @@ -540,14 +553,14 @@ protected function buildExpression(string $directive, string $expression): BaseH 'warning', [ 'class' => 'csp-expression-info', - 'title' => t('This is a potentially unsafe scheme.'), + 'title' => $this->translate('This is a potentially unsafe scheme.'), ] ), 'critical' => new Icon( 'warning', [ 'class' => 'csp-expression-info', - 'title' => t('This is a critical scheme and should not be used.'), + 'title' => $this->translate('This is a critical scheme and should not be used.'), ] ), default => null, @@ -570,7 +583,7 @@ protected function buildExpression(string $directive, string $expression): BaseH 'info-circle', [ 'class' => 'csp-expression-info', - 'title' => t('This is an automatically generated nonce. Its value is unique per request.'), + 'title' => $this->translate('This is an automatically generated nonce. Its value is unique per request.'), ], ), ] From ceeeee34475ab7d2282af71bf945631f8e7f18e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 09:06:46 +0200 Subject: [PATCH 90/96] Reword docstrings --- .../forms/Config/Security/CspConfigForm.php | 84 ++++++++++++++++--- library/Icinga/Application/Hook/CspHook.php | 6 +- .../Csp/Loader/DashboardCspLoader.php | 6 +- .../Security/Csp/Loader/StaticCspLoader.php | 4 +- .../Icinga/Security/Csp/Reason/CspReason.php | 4 +- .../Csp/Reason/DashboardCspReason.php | 9 +- .../Security/Csp/Reason/ModuleCspReason.php | 5 +- .../Csp/Reason/NavigationCspReason.php | 6 +- 8 files changed, 91 insertions(+), 33 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 3e130ea85e..1c7af79b19 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -32,12 +32,19 @@ 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[] */ + /** @var string[] List of all keywords that are considered secure */ protected const SECURE_KEYWORDS = [ "'self'", "'none'", @@ -48,27 +55,27 @@ class CspConfigForm extends CompatForm "'report-sha512'", ]; - /** @var string[] */ + /** @var string[] List of all keywords that should display a warning */ protected const WARNING_KEYWORDS = [ "'unsafe-inline'", "'unsafe-eval'", "'unsafe-hashes'", ]; - /** @var string[] */ + /** @var string[] List of all schemes that are considered secure */ protected const SECURE_SCHEMES = [ 'https', 'wss', ]; - /** @var string[] */ + /** @var string[] List of all schemes that should display a warning */ protected const WARNING_SCHEMES = [ 'http', 'ws', 'blob', ]; - /** @var string[] */ + /** @var string[] List of directives where data is considered critical */ protected const CRITICAL_DATA_DIRECTIVES = [ 'default-src', 'script-src', @@ -76,7 +83,7 @@ class CspConfigForm extends CompatForm 'frame-src', ]; - /** @var string[] */ + /** @var string[] List of directives where data is considered secure */ protected const WARNING_DATA_DIRECTIVES = [ 'style-src', 'worker-src', @@ -85,14 +92,20 @@ class CspConfigForm extends CompatForm ]; /** - * The number of rows for the CUSTOMS CSP textarea + * 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'); @@ -364,16 +377,31 @@ protected function onSuccess(): void $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'; @@ -407,11 +435,13 @@ protected function addDirectiveCheckboxElement( } /** - * @param AttributedCsp[] $attributedCsps the list of cps 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 + * 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 */ @@ -460,6 +490,13 @@ protected function addDirectiveContentElement( )); } + /** + * 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)) { @@ -473,6 +510,14 @@ protected function getKeywordType(string $expression): ?string 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, ':')) { @@ -504,11 +549,26 @@ protected function getSchemeType(string $directive, string $expression): ?string 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 === '*') { diff --git a/library/Icinga/Application/Hook/CspHook.php b/library/Icinga/Application/Hook/CspHook.php index 3ee9f1f1e8..23d63676f3 100644 --- a/library/Icinga/Application/Hook/CspHook.php +++ b/library/Icinga/Application/Hook/CspHook.php @@ -15,13 +15,13 @@ abstract class CspHook { /** - * Allow the module to provide custom directives and policies for the CSP header. - * The return value should be an instance of Csp with the requested policies. + * 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, this instance will be merged with all other requested directives. + * @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; diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index 863d78b1ad..ef40880558 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -19,12 +19,14 @@ * 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.' + * The CSP directive allows the iframe to be embedded on the page. */ class DashboardCspLoader implements CspLoader { /** - * @param User $user + * 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[] */ diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php index 0b1359afc3..c206e99a57 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -16,8 +16,8 @@ class StaticCspLoader implements CspLoader { /** - * @param string $name the name to display for CSP reason - * @param array $directives the CSP directives to load. + * @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( diff --git a/library/Icinga/Security/Csp/Reason/CspReason.php b/library/Icinga/Security/Csp/Reason/CspReason.php index ca9a9ff55f..a3d323e7cd 100644 --- a/library/Icinga/Security/Csp/Reason/CspReason.php +++ b/library/Icinga/Security/Csp/Reason/CspReason.php @@ -6,8 +6,8 @@ namespace Icinga\Security\Csp\Reason; /** - * Base interface for CSP reasons. - * Only used for type hinting. + * Base interface for CSP reasons. Only used for type hinting. + * A reason represents the source of a set of CSP directives. */ interface CspReason { diff --git a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php index ae80f9438f..c06e250a4d 100644 --- a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php +++ b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php @@ -10,15 +10,14 @@ use Icinga\Web\Widget\Dashboard\Pane; /** - * Reason for loading a CSP directive for a dashboard dashlet. - * The CSP directive allows the iframe to be embedded on the page. + * 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 + * @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, diff --git a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php index 3e16b3ecb9..83207ccc0c 100644 --- a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php +++ b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php @@ -6,13 +6,12 @@ namespace Icinga\Security\Csp\Reason; /** - * Reason for loading a CSP directive for a module. - * The CSP directive allows the module to be loaded. + * 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 + * @param string $module The module to load the CSP directive for */ public function __construct( public string $module, diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index a2e10f62dd..ddd05675d1 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -5,11 +5,9 @@ namespace Icinga\Security\Csp\Reason; -use Icinga\Web\Navigation\NavigationItem; - /** - * Reason for loading a CSP directive for a navigation item. - * The CSP directive allows the iframe to be embedded on the page. + * 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 { From 4bc4dc339bbdf72089656ee91ef6a09945ae5780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 09:41:13 +0200 Subject: [PATCH 91/96] Use array for class list --- .../forms/Config/Security/CspConfigForm.php | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 1c7af79b19..57f824161d 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -137,11 +137,15 @@ protected function assemble(): void ); $disabledState = $this->getPopulatedValue('use_custom_csp') === '1'; - $disabledClass = $disabledState ? 'csp-disabled' : ''; + + $formHintClassList = ['csp-form-hint']; + if ($disabledState) { + $formHintClassList[] = 'csp-disabled'; + } $this->add(HtmlElement::create( 'p', - ['class' => ['csp-form-hint', $disabledClass]], + ['class' => $formHintClassList], $this->translate( 'Enabling CSP will block some requests and prevent some functionality from working as expected.' ), @@ -156,13 +160,13 @@ protected function assemble(): void } else { $this->add(HtmlElement::create( 'h3', - ['class' => ['csp-form-hint', $disabledClass]], + ['class' => $formHintClassList], $this->translate('Allowed Sources'), )); $this->add(HtmlElement::create( 'p', - ['class' => ['csp-form-hint', $disabledClass]], + ['class' => $formHintClassList], $this->translate( 'Sources that are used in the generation of the CSP-Header.' ), @@ -170,7 +174,7 @@ protected function assemble(): void $this->add(HtmlElement::create( 'h4', - ['class' => "csp-form-hint $disabledClass"], + ['class' => $formHintClassList], $this->translate('System'), )); @@ -468,8 +472,13 @@ protected function addDirectiveContentElement( return; } + $classList = ['csp-config-table']; + if (! $enabled) { + $classList[] = 'csp-disabled'; + } + $table = new Table(); - $table->addAttributes(Attributes::create(['class' => ['csp-config-table', $enabled ? '' : 'csp-disabled']])); + $table->addAttributes(Attributes::create(['class' => $classList])); $headerRow = Table::tr(); foreach ($header as $h) { $headerRow->add(Table::th($h)); From b7105d114c21a75204e06d857b11a1e37be0fefb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 10:44:13 +0200 Subject: [PATCH 92/96] Gracefully handle the case where there is no owner defined --- .../forms/Config/Security/CspConfigForm.php | 26 ++++++++++++------- .../Csp/Reason/NavigationCspReason.php | 4 +-- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 57f824161d..a2ee0e96b3 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -275,20 +275,26 @@ function (NavigationCspReason $reason, string $directive, string $expression) { } 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), - 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, - ]), + $userCell, Table::td($directive), $this->buildExpression($directive, $expression), ]); diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index ddd05675d1..cb892b942c 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -17,7 +17,7 @@ * @param string|null $parent * @param string $name * @param bool $isShared - * @param string $username + * @param string|null $username */ public function __construct( public string $type, @@ -25,7 +25,7 @@ public function __construct( public ?string $parent, public string $name, public bool $isShared, - public string $username, + public ?string $username, ) { } } From 11ceca2de0fb58c2f546219d72daf8c1357138e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 11:34:51 +0200 Subject: [PATCH 93/96] Rename disabledState to useCustomCsp --- .../forms/Config/Security/CspConfigForm.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index a2ee0e96b3..d6ecb4efa0 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -136,10 +136,10 @@ protected function assemble(): void ], ); - $disabledState = $this->getPopulatedValue('use_custom_csp') === '1'; + $useCustomCsp = $this->getPopulatedValue('use_custom_csp') === '1'; $formHintClassList = ['csp-form-hint']; - if ($disabledState) { + if ($useCustomCsp) { $formHintClassList[] = 'csp-disabled'; } @@ -187,7 +187,7 @@ function (StaticCspReason $reason, string $directive, string $expression) { $this->buildExpression($directive, $expression), ]); }, - ! $disabledState, + ! $useCustomCsp, $this->translate('No system policies defined.') ); @@ -198,7 +198,7 @@ function (StaticCspReason $reason, string $directive, string $expression) { . ' Note: Modules can define or change csp directives at any point.' ), 'csp_enable_modules', - ! $disabledState, + ! $useCustomCsp, ); $this->addDirectiveContentElement( @@ -211,7 +211,7 @@ function (ModuleCspReason $reason, string $directive, string $expression) { $this->buildExpression($directive, $expression), ]); }, - $disabledState === false && $this->getValue('csp_enable_modules') === '1', + $useCustomCsp === false && $this->getValue('csp_enable_modules') === '1', $this->translate('No module policies defined.') ); @@ -223,7 +223,7 @@ function (ModuleCspReason $reason, string $directive, string $expression) { . ' matters to them.' ), 'csp_enable_dashboards', - ! $disabledState, + ! $useCustomCsp, ); $this->addDirectiveContentElement( @@ -244,7 +244,7 @@ function (DashboardCspReason $reason, string $directive, string $expression) { $this->buildExpression($directive, $expression), ]); }, - $disabledState === false && $this->getValue('csp_enable_dashboards') === '1', + $useCustomCsp === false && $this->getValue('csp_enable_dashboards') === '1', $this->translate('No dashboard policies found.'), ); @@ -256,7 +256,7 @@ function (DashboardCspReason $reason, string $directive, string $expression) { . ' directives that actually matters to them.' ), 'csp_enable_navigation', - ! $disabledState, + ! $useCustomCsp, ); $this->addDirectiveContentElement( @@ -299,7 +299,7 @@ function (NavigationCspReason $reason, string $directive, string $expression) { $this->buildExpression($directive, $expression), ]); }, - $disabledState === false && $this->getValue('csp_enable_navigation') === '1', + $useCustomCsp === false && $this->getValue('csp_enable_navigation') === '1', $this->translate('No navigation policies found.'), ); From 4896f2480581d7fa55ad50f19231fa12c17a87b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 12:26:45 +0200 Subject: [PATCH 94/96] Properly check for changes in the configuration This properly handles cases where keys are added or removed from the config --- application/forms/Config/Security/CspConfigForm.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index d6ecb4efa0..dee0b9b129 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -373,10 +373,9 @@ protected function onSuccess(): void $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) - )); + $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; From 48bdb51d41acd0e6436759dcab849ccf6fcf0c9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 13:11:50 +0200 Subject: [PATCH 95/96] Add security to fallback list --- application/controllers/ConfigController.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 43e367fc37..40ace4f674 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -8,7 +8,6 @@ use Exception; use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; -use Icinga\Util\Csp; use InvalidArgumentException; use Icinga\Application\Config; use Icinga\Application\Icinga; @@ -91,6 +90,8 @@ public function indexAction() { if ($this->hasPermission('config/general')) { $this->redirectNow('config/general'); + } elseif ($this->hasPermission('config/security')) { + $this->redirectNow('config/security'); } elseif ($this->hasPermission('config/resources')) { $this->redirectNow('config/resource'); } elseif ($this->hasPermission('config/access-control/*')) { From 7fa22c57a4d797b05b1563c8d28cbacce83e2840 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 19 May 2026 13:44:49 +0200 Subject: [PATCH 96/96] fixup! phpcs --- application/forms/Config/Security/CspConfigForm.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index dee0b9b129..7fbb3a309c 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -657,7 +657,9 @@ protected function buildExpression(string $directive, string $expression): BaseH 'info-circle', [ 'class' => 'csp-expression-info', - 'title' => $this->translate('This is an automatically generated nonce. Its value is unique per request.'), + 'title' => $this->translate( + 'This is an automatically generated nonce. Its value is unique per request.' + ), ], ), ]