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 96c6a860c..3e2391697 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 @@ class ApplicationConfigForm extends Form '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 @@ class ApplicationConfigForm extends Form ] ); + 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 000000000..43eb6c26e --- /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 d5fbdfd52..69907f28e 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 @@ class Csp */ 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 @@ class Csp public static function createNonce(): void { $csp = static::getInstance(); - $csp->styleNonce = base64_encode(random_bytes(16)); - - Window::getInstance()->getSessionNamespace('csp')->set('style_nonce', $csp->styleNonce); + if ($csp->styleNonce === null) { + $csp->styleNonce = base64_encode(random_bytes(16)); + Window::getInstance()->getSessionNamespace('csp')->set('style_nonce', $csp->styleNonce); + } } /** @@ -80,7 +202,10 @@ class Csp */ 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 000000000..c3cdb4c63 --- /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 69907f28e..25879031c 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -86,7 +86,7 @@ class Csp // 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 @@ class Csp } $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 25879031c..54c96fab7 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -14,7 +14,10 @@ use Icinga\Data\ConfigObject; 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 @@ class Csp 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 @@ class Csp * * @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 @@ class Csp // 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 @@ class Csp $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 @@ class Csp $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 @@ class Csp 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 c3cdb4c63..000000000 --- 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 54c96fab7..e30747774 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -105,7 +105,7 @@ class Csp $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 @@ class Csp $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 @@ class Csp 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 @@ class Csp $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 e30747774..8105071b0 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -56,17 +56,13 @@ class Csp */ 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 @@ class Csp */ 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 @@ class Csp */ 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; + } - // Otherwise, check if the dashlet URL itself is external - if ($url->isExternal()) { + $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; + } + + 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 | 185 ++++++++++++------ 3 files changed, 164 insertions(+), 57 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 7a1246fa8..a64dcaf37 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -7,6 +7,7 @@ namespace Icinga\Controllers; 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\Controller; use Icinga\Web\Notification; use Icinga\Web\Url; use Icinga\Web\Widget; +use ipl\Html\Table; /** * Application and module configuration @@ -113,6 +115,38 @@ class ConfigController extends Controller $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 13a8ed9ed..4731c4136 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 8105071b0..054fed5df 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -60,6 +60,91 @@ class Csp $response->setHeader('Content-Security-Policy', $header, true); } + 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'], + ]; +// +// $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)) { + $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) { +// $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. * @@ -84,63 +169,18 @@ class Csp 'frame-src' => [] ]; - // 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']); + $policyDirectives = self::collectContentSecurityPolicyDirectives(); - $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"; - } - - $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) { - $cspDirectives[$directive][] = $policy; - } + 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 @@ class Csp $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 @@ class Csp 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 @@ class Csp 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 | 64 ++++++------------- .../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, 137 insertions(+), 65 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 a64dcaf37..235c21925 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\Controller; use Icinga\Web\Notification; use Icinga\Web\Url; use Icinga\Web\Widget; -use ipl\Html\Table; /** * Application and module configuration @@ -98,54 +99,25 @@ class ConfigController extends Controller public function generalAction() { $this->assertPermission('config/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(); - - $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)), - ])); - } - } + $form = new GeneralConfigForm(); + $form->setIniConfig(Config::app()); + $form->handleRequest(); - $this->view->cspTable = $table->render(); - } + $this->view->form = $form; + + $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 = (new CspConfigurationTable())->render(); $this->createApplicationTabs()->activate('general'); } diff --git a/application/forms/Config/General/ApplicationConfigForm.php b/application/forms/Config/General/ApplicationConfigForm.php index 3e2391697..47a21c8da 100644 --- a/application/forms/Config/General/ApplicationConfigForm.php +++ b/application/forms/Config/General/ApplicationConfigForm.php @@ -59,25 +59,6 @@ class ApplicationConfigForm extends Form ) ); - $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 000000000..2cd285f6a --- /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 4731c4136..ecb387c8d 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 000000000..fb0e243de --- /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 c482f0ce5..fa3dcf0c6 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 054fed5df..7632cd66c 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -142,6 +142,8 @@ class Csp ]; } + $policyDirectives = array_merge($policyDirectives, self::fetchCustomCspDirectives()); + return $policyDirectives; } @@ -173,6 +175,9 @@ class Csp 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 @@ class Csp 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 43eb6c26e..451248be7 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 | 129 ++++++++++++++++-------------------- 1 file changed, 56 insertions(+), 73 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 7632cd66c..556aaa91a 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -19,6 +19,7 @@ use RuntimeException; use Icinga\Web\Navigation\Navigation; use Icinga\Web\Widget\Dashboard; +use Throwable; use function ipl\Stdlib\get_php_type; /** @@ -95,66 +96,78 @@ class Csp ], '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"); + 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; } - // 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) { -// $cspDirectives[$directive][] = $policy; -// } - - if (count($policies) === 0) { - continue; - } - - $directives[$directive] = $policies; + $policyDirectives[] = [ + "directives" => $directives, + "reason" => [ + "type" => "hook", + "hook" => get_class($hook), + ], + ]; + } catch (Throwable $e) { + Logger::error('Failed to CSP hook on request: %s', $e); } - - if (count($directives) === 0) { - continue; - } - - $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 @@ class Csp } // 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 @@ class Csp return $header; } + /** * Set/recreate nonce for dynamic CSS * @@ -246,37 +260,6 @@ class Csp 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 235c21925..145ccdd82 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -108,11 +108,11 @@ class ConfigController extends Controller $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 2cd285f6a..fe61373b3 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 @@ class CspConfigForm extends CompatForm ], ); + $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 @@ class CspConfigForm extends CompatForm $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 556aaa91a..44716c4bd 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 @@ use function ipl\Stdlib\get_php_type; */ 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 @@ class Csp } /** - * Add Content-Security-Policy header with a nonce for dynamic CSS + * Add a Content-Security-Policy header with a nonce for dynamic CSS * * Note that {@see static::createNonce()} must be called beforehand. * @@ -61,6 +59,11 @@ class Csp $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 @@ class Csp * @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 @@ class Csp * @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 @@ class Csp * @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 19c25ddbb..7a9e6033b 100644 --- a/library/Icinga/Web/Response.php +++ b/library/Icinga/Web/Response.php @@ -383,7 +383,7 @@ class Response extends Zend_Controller_Response_Http $this->setRedirect($redirectUrl->getAbsoluteUrl()); } - if (Csp::getStyleNonce() && Config::app()->get('security', 'use_strict_csp', false)) { + if (Csp::getStyleNonce() && Csp::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 fe61373b3..d81897555 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -56,14 +56,25 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $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 44716c4bd..a82c9cb67 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -157,12 +157,26 @@ class Csp { $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 a82c9cb67..00e5b13d3 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -163,7 +163,7 @@ class Csp return self::getAutomaticContentSecurityPolicy(); } - public static function getCustomContentSecurityPolicy(): ?string + protected static function getCustomContentSecurityPolicy(): ?string { $csp = static::getInstance(); @@ -172,9 +172,11 @@ class Csp } $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 d81897555..670bfc953 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -55,13 +55,25 @@ class CspConfigForm extends CompatForm $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 | 143 ++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 72 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 00e5b13d3..dbe0c6dcc 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\Response; -use Icinga\Web\Window; -use Icinga\Application\Config; -use RuntimeException; use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Response; use Icinga\Web\Widget\Dashboard; - +use Icinga\Web\Window; +use RuntimeException; use Throwable; use function ipl\Stdlib\get_php_type; @@ -97,7 +96,7 @@ class Csp 'directives' => [ 'frame-src' => [$policy], ], - 'reason' => $navigationItem['reason'], + 'reason' => $navigationItem['reason'], ]; } @@ -149,9 +148,9 @@ class Csp /** * 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 @@ class Csp /** * 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 @@ class Csp // 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 @@ class Csp $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 @@ class Csp 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 @@ class Csp { return array_merge( self::fetchNavigationItems(), - self::fetchDashletsItems() + self::fetchDashletsItems(), ); } @@ -316,31 +317,30 @@ class Csp 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 @@ class Csp */ protected static function fetchDashletsItems(): array { - $user = Auth::getInstance()->getUser(); - $dashlets = []; - if ($user === null) { - return $dashlets; - } + $user = Auth::getInstance()->getUser(); + $dashlets = []; + if ($user === null) { + return $dashlets; + } - $dashboard = new Dashboard(); - $dashboard->setUser($user); - $dashboard->load(); + $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; - } + 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; - } - - 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 | 72 +++++++++++++++++++------------------ 1 file changed, 38 insertions(+), 34 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index dbe0c6dcc..fe24e826d 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\Application\Logger; 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 @@ class Csp * 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, - ], - ]; - } - } - $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, + 'name' => $name, + 'parent' => $navItem->getName(), + 'navType' => $type, ], ]; } } } - return $menuItems; + + 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(); + } + } } /** * 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 fb0e243de..2bb782f9f 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -30,6 +30,9 @@ class CspConfigurationTable extends Table $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 670bfc953..d02ad2af3 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -82,6 +82,7 @@ class CspConfigForm extends CompatForm $customCspElement = $this->getElement('custom_csp'); if ($this->hasBeenSubmitted()) { if (! $useCustomCsp) { + Csp::createNonce(); $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); } return; @@ -96,6 +97,7 @@ class CspConfigForm extends CompatForm } } 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 d02ad2af3..0292875e8 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 0292875e8..1062a3704 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -7,9 +7,11 @@ namespace Icinga\Forms\Config\General; 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 @@ class CspConfigForm extends CompatForm '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 @@ class CspConfigForm extends CompatForm $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 fe24e826d..bde131e51 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -67,40 +67,7 @@ class Csp 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 @@ class Csp * 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 @@ class Csp * 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 @@ class Csp 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 @@ class Csp /** * 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 bde131e51..b7a9aebff 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -11,7 +11,6 @@ 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\Navigation\NavigationItem; use Icinga\Web\Response; @@ -67,51 +66,13 @@ class Csp 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 @@ class Csp */ 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 @@ class Csp 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 @@ class Csp 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 @@ class Csp * 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 @@ class Csp } } } - - return $origins; } protected static function yieldNavigation(NavigationItem $item): Generator @@ -321,14 +326,13 @@ class Csp /** * 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 @@ class Csp continue; } - $origins[] = [ + yield [ 'directives' => [ 'frame-src' => [$absoluteUrl], ], @@ -364,6 +368,5 @@ class Csp ]; } } - 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 93af32850..58351fc06 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 @@ abstract class CspDirectiveHook } /** - * 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 b7a9aebff..45a4bbfe0 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -64,13 +64,19 @@ class Csp 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 @@ class Csp 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 @@ class Csp * * @return string Returns the generated header value. * @throws RuntimeException If no nonce set for CSS - * */ public static function getAutomaticContentSecurityPolicy(): string { @@ -197,6 +208,12 @@ class Csp 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 @@ class Csp } } + /** + * 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 @@ class Csp } } + /** + * 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 @@ class Csp * * @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 1062a3704..27a559552 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -63,7 +63,7 @@ class CspConfigForm extends CompatForm $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 45a4bbfe0..9dc7edf19 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 @@ class Csp } 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 c23fe3bbe..ad5b4b882 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -36,7 +36,7 @@ class CspConfigurationTable extends Table . '/' . ($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 47a21c8da..d33b86582 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 ad5b4b882..169609672 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -19,11 +19,11 @@ class CspConfigurationTable extends 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')), + $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 @@ class CspConfigurationTable extends Table 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 | 105 ++++++++++-------- 2 files changed, 64 insertions(+), 48 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 145ccdd82..0aeaff40b 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -9,6 +9,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; @@ -117,7 +118,11 @@ class ConfigController extends Controller $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 27a559552..ee8938d30 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -34,63 +34,71 @@ class CspConfigForm extends CompatForm '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 setting helps to protect from cross-site scripting (XSS).', ), + 'class' => 'autosubmit', ], ); - $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', - ] - ); + $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'); + $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()); + $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('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 @@ class CspConfigForm extends CompatForm $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 0aeaff40b..42aa195dc 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -9,7 +9,6 @@ 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; @@ -29,6 +28,7 @@ use Icinga\Web\Controller; use Icinga\Web\Notification; use Icinga\Web\Url; use Icinga\Web\Widget; +use ipl\Html\Form; /** * Application and module configuration @@ -115,6 +115,13 @@ class ConfigController extends Controller '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 58351fc06..e592cdc86 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspDirectiveHook.php @@ -6,6 +6,10 @@ namespace Icinga\Application\Hook; 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 42aa195dc..c38887147 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -9,6 +9,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; @@ -112,12 +113,12 @@ class ConfigController extends Controller $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 @@ class ConfigController extends Controller $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 ee8938d30..77ebccfa4 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -43,7 +43,7 @@ class CspConfigForm extends CompatForm ], ); - $useCsp = $this->getPopulatedValue('use_strict_csp', 'n') === 'y'; + $useCsp = $this->getValue('use_strict_csp') === 'y'; if ($useCsp) { $this->addElement( 'checkbox', @@ -59,7 +59,7 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm } 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 9dc7edf19..8ce14b218 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -62,7 +62,9 @@ class Csp 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 @@ class Csp $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 c38887147..3c07ec2a4 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -115,6 +115,7 @@ class ConfigController extends Controller $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 77ebccfa4..ebbc9e1c9 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -43,8 +43,7 @@ class CspConfigForm extends CompatForm ], ); - $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 @@ class CspConfigForm extends CompatForm ], ); - $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 @@ class CspConfigForm extends CompatForm '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 @@ class CspConfigForm extends CompatForm $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 3c07ec2a4..6107b1ead 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -113,7 +113,7 @@ class ConfigController extends Controller $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 ebbc9e1c9..f98498794 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -85,7 +85,7 @@ class CspConfigForm extends CompatForm . ' 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 8ce14b218..92cc3d662 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -56,11 +56,11 @@ class Csp */ 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 @@ class Csp * * @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 @@ class Csp } /** - * 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 @@ class Csp * * @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 @@ class Csp * @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 7a9e6033b..6cb25ae16 100644 --- a/library/Icinga/Web/Response.php +++ b/library/Icinga/Web/Response.php @@ -383,7 +383,7 @@ class Response extends Zend_Controller_Response_Http $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 169609672..d7a14adcf 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -26,7 +26,7 @@ class CspConfigurationTable extends Table 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 6107b1ead..c048ed23d 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -123,6 +123,7 @@ class ConfigController extends Controller 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 c048ed23d..4f945bacb 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -113,8 +113,8 @@ class ConfigController extends Controller $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 92cc3d662..785503901 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -144,16 +144,16 @@ class Csp } } - $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 f98498794..2ba477d95 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -43,7 +43,10 @@ class CspConfigForm extends CompatForm ], ); - 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 4f945bacb..a63deceb9 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -29,6 +29,7 @@ use Icinga\Web\Controller; 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 @@ class ConfigController extends Controller '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 @@ class ConfigController extends Controller $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 2ba477d95..524f2da4f 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 @@ class CspConfigForm extends CompatForm ], ); - 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 @@ class CspConfigForm extends CompatForm ], ); - if ($this->getValue('use_custom_csp') === 'y') { + if ($this->isCustomCspEnabled()) { $this->addHtml((new Callout( CalloutType::Warning, $this->translate( @@ -103,17 +105,39 @@ class CspConfigForm extends CompatForm $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 785503901..623334d0b 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -342,14 +342,14 @@ class Csp 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 623334d0b..dad7f7384 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -15,6 +15,7 @@ use Icinga\Authentication\Auth; 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 @@ class Csp $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 @@ class Csp 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 dad7f7384..649715f21 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -326,7 +326,7 @@ class Csp '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 524f2da4f..6402f7dff 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -36,12 +36,14 @@ class CspConfigForm extends CompatForm '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 @@ class CspConfigForm extends CompatForm '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 @@ class CspConfigForm extends CompatForm 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 649715f21..22f9b324d 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -63,9 +63,7 @@ class Csp 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 @@ class Csp 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 22f9b324d..674efaae8 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -401,7 +401,6 @@ class Csp ], '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 674efaae8..500813a04 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -68,19 +68,15 @@ class Csp /** * 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 @@ class Csp 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 @@ class Csp } } + 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 d7a14adcf..a32441729 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -26,10 +26,8 @@ class CspConfigurationTable extends Table 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 @@ class CspConfigurationTable extends Table '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 | 224 +++++++++++++++--- 1 file changed, 197 insertions(+), 27 deletions(-) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index a32441729..46e05b279 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 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']; + 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']; - $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)), - ])); + 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 + { + $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 46e05b279..564422b4e 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -21,18 +21,14 @@ class CspConfigurationTable extends BaseHtmlElement $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 @@ class CspConfigurationTable extends BaseHtmlElement 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 @@ class CspConfigurationTable extends BaseHtmlElement $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 @@ class CspConfigurationTable extends BaseHtmlElement $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 @@ class CspConfigurationTable extends BaseHtmlElement $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 @@ class CspConfigurationTable extends BaseHtmlElement $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 564422b4e..83c23079c 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -9,6 +9,7 @@ use ipl\Html\BaseHtmlElement; 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 @@ class CspConfigurationTable extends 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 a63deceb9..cc594ba8f 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -128,12 +128,6 @@ class ConfigController extends Controller $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 6402f7dff..d161e5dd5 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -7,6 +7,8 @@ namespace Icinga\Forms\Config\General; 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 @@ class CspConfigForm extends CompatForm '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 ecb387c8d..a5ab32b78 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 fc7a025f1..e2039b08a 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 000000000..b83219b41 --- /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 fa3dcf0c6..c482f0ce5 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 d161e5dd5..9224a641c 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -39,9 +39,9 @@ class CspConfigForm extends CompatForm '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 83c23079c..dbdf19b78 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -78,7 +78,7 @@ class CspConfigurationTable extends BaseHtmlElement function (array $reason, string $directive, string $policy) { return Table::tr([ Table::td($directive), - $this->buildPolicy($policy), + $this->buildPolicy($directive, $policy), ]); }, ); @@ -93,7 +93,7 @@ class CspConfigurationTable extends BaseHtmlElement Table::td($reason['pane']), Table::td($reason['dashlet']), Table::td($directive), - $this->buildPolicy($policy), + $this->buildPolicy($directive, $policy), ]); } ); @@ -110,7 +110,7 @@ class CspConfigurationTable extends BaseHtmlElement Table::td($reason['name']), Table::td($reason['parent'] ?? 'NA'), Table::td($directive), - $this->buildPolicy($policy), + $this->buildPolicy($directive, $policy), ]); } ); @@ -124,7 +124,7 @@ class CspConfigurationTable extends BaseHtmlElement return Table::tr([ Table::td($reason['module']), Table::td($directive), - $this->buildPolicy($policy), + $this->buildPolicy($directive, $policy), ]); } ); @@ -159,7 +159,7 @@ class CspConfigurationTable extends BaseHtmlElement 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 @@ class CspConfigurationTable extends BaseHtmlElement 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 @@ class CspConfigurationTable extends BaseHtmlElement 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 @@ class CspConfigurationTable extends 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 b83219b41..86635f766 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 dbdf19b78..897e0d8da 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 @@ class CspConfigurationTable extends BaseHtmlElement 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 @@ class CspConfigurationTable extends BaseHtmlElement $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 @@ class CspConfigurationTable extends BaseHtmlElement { 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 cc594ba8f..2cea49217 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -119,8 +119,9 @@ class ConfigController extends Controller '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 500813a04..c6d1a7549 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -136,15 +136,13 @@ class Csp } } - 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 897e0d8da..ef5d5f276 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 @@ class CspConfigurationTable extends BaseHtmlElement 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 ef5d5f276..3bb99cc30 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -226,15 +226,15 @@ class CspConfigurationTable extends BaseHtmlElement 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 86635f766..cadfcc5cd 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 2cea49217..5ccde53a0 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -117,6 +117,7 @@ class ConfigController extends Controller '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 9224a641c..c77e07cbe 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -53,6 +53,7 @@ class CspConfigForm extends CompatForm 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 @@ class CspConfigForm extends CompatForm ); if ($this->isCustomCspEnabled()) { + $this->addElement('hidden', 'include_user_content'); + $this->addHtml((new Callout( CalloutType::Warning, $this->translate( @@ -89,6 +92,22 @@ class CspConfigForm extends CompatForm } 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 @@ class CspConfigForm extends CompatForm . ' 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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm 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 c6d1a7549..c62f68b28 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -69,14 +69,21 @@ class Csp /** * 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 @@ class Csp * @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 3bb99cc30..7eca7f85d 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 @@ class CspConfigurationTable extends BaseHtmlElement 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 5ccde53a0..b363f0d74 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -8,9 +8,7 @@ namespace Icinga\Controllers; 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\Exception\NotFoundError; use Icinga\Forms\ActionForm; use Icinga\Forms\Config\GeneralConfigForm; use Icinga\Forms\Config\ResourceConfigForm; +use Icinga\Forms\Config\Security\CspConfigForm; use Icinga\Forms\Config\UserBackendConfigForm; use Icinga\Forms\Config\UserBackendReorderForm; use Icinga\Forms\ConfirmRemovalForm; @@ -30,7 +29,6 @@ use Icinga\Web\Notification; 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 @@ class ConfigController extends Controller 'baseTarget' => '_main' )); } + if ($this->hasPermission('config/security')) { + $tabs->add('security', array( + 'title' => $this->translate('Adjust the security configuration of Icinga Web 2'), + 'label' => $this->translate('Security'), + 'url' => 'config/security', + 'baseTarget' => '_main' + )); + } if ($this->hasPermission('config/resources')) { $tabs->add('resource', array( 'title' => $this->translate('Configure which resources are being utilized by Icinga Web 2'), @@ -111,6 +117,20 @@ class ConfigController extends Controller $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 @@ class ConfigController extends Controller $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 c77e07cbe..38c4baa5d 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 ea64fd0cb..37ce3e067 100644 --- a/application/forms/Security/RoleForm.php +++ b/application/forms/Security/RoleForm.php @@ -548,6 +548,9 @@ class RoleForm extends RepositoryForm 'config/general' => [ 'description' => t('Allow to adjust the general configuration') ], + 'config/security' => [ + 'description' => t('Allow to adjust the security configuration') + ], 'config/modules' => [ 'description' => t('Allow to enable/disable and configure modules') ], diff --git a/application/views/scripts/config/general.phtml b/application/views/scripts/config/general.phtml index a5ab32b78..13a8ed9ed 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 000000000..24208eaf8 --- /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 b363f0d74..69f2cd943 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -125,7 +125,7 @@ class ConfigController extends Controller * * @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 @@ class ConfigController extends Controller '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 38c4baa5d..e259967f8 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -116,7 +116,7 @@ class CspConfigForm extends CompatForm . ' 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 000000000..5fdee5de4 --- /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 000000000..9ec259c87 --- /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 000000000..81fb9c086 --- /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 000000000..4370ba00c --- /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 000000000..0aa546843 --- /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 @@ class Csp { $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 @@ class Csp $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 @@ class Csp 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 7eca7f85d..e03b4e1d9 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 @@ class CspConfigurationTable extends BaseHtmlElement ) { } + /** + * @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 @@ class CspConfigurationTable extends BaseHtmlElement 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 @@ class CspConfigurationTable extends BaseHtmlElement $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 3f804b7f0..686019c56 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 1c9b32ea4..57495ae5b 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -76,11 +76,11 @@ class Csp $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 e03b4e1d9..72f74a265 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -98,8 +98,7 @@ class CspConfigurationTable extends BaseHtmlElement 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 57495ae5b..016741d8a 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -16,7 +16,6 @@ use Icinga\Web\Response; use Icinga\Web\Window; use ipl\Web\Common\Csp as CspInstance; use RuntimeException; -use function ipl\Stdlib\get_php_type; /** * Helper to enable strict content security policy (CSP) @@ -31,11 +30,8 @@ use function ipl\Stdlib\get_php_type; */ 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 @@ class Csp 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 @@ class Csp */ 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 @@ class Csp '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 @@ class Csp */ 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 @@ class Csp */ 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 @@ class 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 @@ class Csp */ 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 e259967f8..27b239204 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 @@ class CspConfigForm extends CompatForm '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 | 536 +++++++++++++++--- library/Icinga/Util/Csp.php | 25 +- .../Web/Widget/CspConfigurationTable.php | 275 --------- public/css/icinga/csp-config-editor.less | 70 ++- 5 files changed, 527 insertions(+), 383 deletions(-) delete mode 100644 library/Icinga/Web/Widget/CspConfigurationTable.php diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 69f2cd943..3826d3a4b 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -137,7 +137,9 @@ class ConfigController extends Controller '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 27b239204..605bf4e17 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -6,10 +6,20 @@ namespace Icinga\Forms\Config\Security; use Exception; use Icinga\Application\Config; +use Icinga\Data\ConfigObject; +use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\Reason\CspReason; +use Icinga\Security\Csp\Reason\DashboardCspReason; +use Icinga\Security\Csp\Reason\ModuleCspReason; +use Icinga\Security\Csp\Reason\NavigationCspReason; +use Icinga\Security\Csp\Reason\StaticCspReason; use Icinga\Util\Csp; use Icinga\Web\Session; -use 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\CsrfCounterMeasure; use ipl\Web\Common\FormUid; use ipl\Web\Compat\CompatForm; use ipl\Web\Widget\Callout; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; class CspConfigForm extends CompatForm { use FormUid; use CsrfCounterMeasure; + /** @var string[] */ + protected const SECURE_KEYWORDS = [ + "'self'", + "'none'", + "'strict-dynamic'", + "'report-sample'", + "'report-sha256'", + "'report-sha384'", + "'report-sha512'", + ]; + + /** @var string[] */ + protected const WARNING_KEYWORDS = [ + "'unsafe-inline'", + "'unsafe-eval'", + "'unsafe-hashes'", + ]; + + /** @var string[] */ + protected const SECURE_SCHEMAS = [ + 'https', + 'wss', + ]; + + /** @var string[] */ + protected const WARNING_SCHEMAS = [ + 'http', + 'ws', + 'blob', + ]; + + /** @var string[] */ + protected const CRITICAL_DATA_DIRECTIVES = [ + 'default-src', + 'script-src', + 'object-src', + 'frame-src', + ]; + + /** @var string[] */ + protected const WARNING_DATA_DIRECTIVES = [ + 'style-src', + 'worker-src', + 'child-src', + 'base-uri', + ]; + + /** + * The number of rows for the CUSTOMS CSP textarea + * + * @const int + */ + protected const TEXTAREA_ROWS = 8; + protected bool $changed = false; public function __construct(protected Config $config) { - $this->setAttribute("name", "csp_config"); - $this->getAttributes()->add("class", "csp-config-form"); + $this->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 @@ class CspConfigForm extends CompatForm ], ); + $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 @@ class CspConfigForm extends CompatForm '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 @@ class CspConfigForm extends CompatForm ), $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; - } - - 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(), - ]); - - $this->add(HtmlElement::create( - 'div', - [ - 'class' => 'collapsible', - 'data-visible-height' => 250, - ], - new CspConfigurationTable($this->shouldIncludeUserContent()), - )); } + + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate(''), + 'description' => $this->translate( + 'Set a custom CSP-Header. This completely overrides the automatically generated one.' + . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.', + ), + 'rows' => static::TEXTAREA_ROWS, + 'disabled' => ! $this->isCustomCspEnabled(), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator) { + if (empty($value)) { + return true; + } + + try { + $value = str_replace('{style_nonce}', "'nonce-validation'", $value); + CspInstance::fromString($value); + } catch (Exception $e) { + $validator->addMessage($e->getMessage()); + return false; + } + + return true; + }), + ] + ]); } $this->addElement('submit', 'submit', [ @@ -162,14 +327,11 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm 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 @@ class CspConfigForm extends CompatForm 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 016741d8a..e73519d07 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -7,6 +7,7 @@ namespace Icinga\Util; 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 @@ class Csp /** * @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 @@ class Csp ] ))->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 @@ class Csp } /** - * 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 72f74a265..000000000 --- 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 cadfcc5cd..606f7362a 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; } - .btn-primary { - margin-top: 1em; + .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; + } + + .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 3826d3a4b..43e367fc3 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -129,14 +129,14 @@ class ConfigController extends Controller { $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 605bf4e17..12701c7e6 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -331,7 +331,7 @@ class CspConfigForm extends CompatForm $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 686019c56..54ee094f6 100644 --- a/library/Icinga/Security/Csp/Loader/CspLoader.php +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -6,15 +6,15 @@ namespace Icinga\Security\Csp\Loader; 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 ef0e3dc7a..8c710049a 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -16,7 +16,7 @@ use RuntimeException; * 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 9ec259c87..91167cee5 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -15,7 +15,7 @@ use Throwable; * 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 81fb9c086..cd6bfb53b 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -15,7 +15,7 @@ use RuntimeException; * 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 4370ba00c..565d8c519 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -10,7 +10,7 @@ use Icinga\Security\Csp\Reason\StaticCspReason; * 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 0aa546843..d56b64df9 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 3d0632838..495f1f1c1 100644 --- a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php +++ b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php @@ -10,15 +10,15 @@ 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. */ -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 a5d4e8242..d6dc7acb8 100644 --- a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php +++ b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php @@ -7,13 +7,13 @@ namespace Icinga\Security\Csp\Reason; * 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 f9c8e8c46..1e69fd53d 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -9,11 +9,11 @@ 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. */ -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 0716d3a8d..ac8715dc2 100644 --- a/library/Icinga/Security/Csp/Reason/StaticCspReason.php +++ b/library/Icinga/Security/Csp/Reason/StaticCspReason.php @@ -7,13 +7,13 @@ namespace Icinga\Security\Csp\Reason; * 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 e73519d07..96c7a06e0 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -50,8 +50,7 @@ class Csp */ 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 12701c7e6..c97d50bcc 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 @@ class CspConfigForm extends CompatForm 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 c97d50bcc..13ba27595 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -564,7 +564,7 @@ class CspConfigForm extends CompatForm ] ); } 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 13ba27595..1d5fc47ff 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -322,9 +322,7 @@ class CspConfigForm extends CompatForm 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 @@ class CspConfigForm extends CompatForm return; } - $config->saveIni(); + $this->config->setSection('security', $section); + + $this->config->saveIni(); } public function hasConfigChanged(): bool @@ -392,6 +392,7 @@ class CspConfigForm extends CompatForm '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 96c7a06e0..407acd088 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 @@ class Csp 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 @@ class Csp ] ))->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 e592cdc86..aa974b53d 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 @@ use Icinga\Application\Hook; 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 5fdee5de4..66b1b7d2e 100644 --- a/library/Icinga/Security/Csp/LoadedCsp.php +++ b/library/Icinga/Security/Csp/LoadedCsp.php @@ -19,4 +19,11 @@ class LoadedCsp extends Csp 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 91167cee5..9da20af74 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -28,18 +28,15 @@ class ModuleCspLoader implements CspLoader $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 aa974b53d..5cb3b3e45 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspHook.php @@ -8,18 +8,18 @@ use Icinga\Application\Hook; 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 class CspDirectiveHook */ 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 9da20af74..74cce41c0 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 @@ class ModuleCspLoader implements CspLoader { $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 1d5fc47ff..0e469c72c 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -252,15 +252,6 @@ class CspConfigForm extends CompatForm }, ); -// $this->add(HtmlElement::create( -// 'div', -// [ -// 'class' => 'collapsible', -// 'data-visible-height' => 250, -// ], -// $table, -// )); - $this->addElement( 'checkbox', 'use_custom_csp', @@ -433,7 +424,14 @@ class CspConfigForm extends CompatForm $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 606f7362a..d49c81b40 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 0e469c72c..4a1ec6019 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -158,38 +158,40 @@ class CspConfigForm extends CompatForm ), )); - $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $this->buildPolicy($directive, $policy), ]); }, + $disabledState === false && $this->getValue('csp_enable_navigation') === '1', + $this->translate('No navigation policies found.'), ); $this->addElement( @@ -352,47 +366,55 @@ class CspConfigForm extends CompatForm } /** - * @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 @@ class CspConfigForm extends CompatForm 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 d49c81b40..2307ab6ec 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 8c710049a..2e6047e8e 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\LoadedCsp; 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 @@ class DashboardCspLoader implements CspLoader { $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 cd6bfb53b..56544b75a 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\LoadedCsp; 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 @@ class NavigationCspLoader implements CspLoader $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 4a1ec6019..5e9b9d2bd 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 5cb3b3e45..269b3a5f9 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 66b1b7d2e..c9ba26cc8 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 54ee094f6..6fe3f6bdb 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 2e6047e8e..f7e8aa4fa 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 74cce41c0..f4c9cb2f2 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 56544b75a..14029aca6 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 565d8c519..6146ec4db 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 d56b64df9..ca9a9ff55 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 495f1f1c1..62ce62a8f 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 d6dc7acb8..3e16b3ecb 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 1e69fd53d..f27961952 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 ac8715dc2..a85bd48bf 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 5e9b9d2bd..a8662617e 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -256,7 +256,7 @@ class CspConfigForm extends CompatForm $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 14029aca6..90cab3a0d 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -35,8 +35,8 @@ class NavigationCspLoader implements CspLoader 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 @@ class NavigationCspLoader implements CspLoader $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 f27961952..5ac8576dc 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -13,8 +13,14 @@ use Icinga\Web\Navigation\NavigationItem; */ 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 89160bca0..4aaf7b3a0 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 a144a5be0..88b47d6ca 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 2dc645d99..f1e7ef050 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 a8662617e..b420b6036 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -159,25 +159,25 @@ class CspConfigForm extends CompatForm ), )); - $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 @@ class CspConfigForm extends CompatForm ! $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 @@ class CspConfigForm extends CompatForm ! $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 @@ class CspConfigForm extends CompatForm ! $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm * * @return void */ - protected function addPolicyTitleElement( + protected function addDirectiveTitleElement( string $title, ?string $description, ?string $field, @@ -408,7 +408,7 @@ class CspConfigForm extends CompatForm * * @return void */ - protected function addPolicyContentElement( + protected function addDirectiveContentElement( array $csps, array $header, callable $filter, @@ -421,9 +421,9 @@ class CspConfigForm extends CompatForm 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 @@ class CspConfigForm extends CompatForm )); } - 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 @@ class CspConfigForm extends CompatForm 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 @@ class CspConfigForm extends CompatForm '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 @@ class CspConfigForm extends CompatForm '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 2307ab6ec..b780d2e60 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 407acd088..c556e818b 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -61,6 +61,68 @@ class Csp } /** + * 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 02cba4ce0..3ad94ed8b 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 @@ class DashletForm extends Form ) ); + if (Csp::isEnabled() && ! Csp::isDashboardEnabled()) { + $this->addElement( + 'note', + 'csp_warning', + [ + 'decorators' => ['ViewHelper'], + 'value' => (new Callout( + CalloutType::Info, + $this->translate( + 'Any external url is not guaranteed to work as expected. ' + . 'Please make sure to check the Content-Security-Policy configuration.' + ), + $this->translate('Dashboards are not enabled in the CSP configuration'), + ))->setFormElement()->render(), + ] + ); + } + $this->addElement( 'textarea', 'url', diff --git a/application/forms/Navigation/NavigationItemForm.php b/application/forms/Navigation/NavigationItemForm.php index 0efe96d9c..5029ab590 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 @@ class NavigationItemForm extends Form ) ); + if (Csp::isEnabled() && ! Csp::isNavigationEnabled()) { + $this->addElement( + 'note', + 'csp_warning', + [ + 'decorators' => ['ViewHelper'], + 'value' => (new Callout( + CalloutType::Info, + $this->translate( + 'Any external url is not guaranteed to work as expected. ' + . 'Please make sure to check the Content-Security-Policy configuration.' + ), + $this->translate('Navigation items are not enabled in the CSP configuration'), + ))->setFormElement()->render(), + ] + ); + } + $this->addElement( 'textarea', 'url', diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index c556e818b..5c1690c3d 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -55,6 +55,10 @@ class Csp $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 | 55 +++++++++++++++---- .../Csp/Reason/DashboardCspReason.php | 3 + 3 files changed, 63 insertions(+), 20 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index b420b6036..eca4c4b75 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -7,9 +7,12 @@ namespace Icinga\Forms\Config\Security; 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 @@ class CspConfigForm extends CompatForm { 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 @@ class CspConfigForm extends CompatForm ); $this->addDirectiveContentElement( - $csps, + (new ModuleCspLoader())->load(), [t('Module'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof ModuleCspReason; @@ -207,16 +210,17 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm 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 @@ class CspConfigForm extends CompatForm ); $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 f7e8aa4fa..0c5ef8adf 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 @@ use Icinga\Web\Widget\Dashboard; class DashboardCspLoader implements CspLoader { /** - * Fetches all dashlets for the current user that have an external URL. - * - * @return LoadedCsp[] A list of CSP directives, one for each dashlet that has an external URL. + * @param bool $allUsers whether to load CSP directives for all users, or only the current user */ - public function load(): array - { - $user = Auth::getInstance()->getUser(); - if ($user === null) { - return []; - } + public function __construct( + protected bool $allUsers = false, + ) { + } + /** + * @param User $user + * + * @return LoadedCsp[] + */ + protected function loadForUser(User $user): array + { $dashboard = new Dashboard(); $dashboard->setUser($user); $dashboard->load(); @@ -60,7 +66,7 @@ class DashboardCspLoader implements CspLoader $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 @@ class DashboardCspLoader implements CspLoader 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 62ce62a8f..ae80f9438 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 @@ use Icinga\Web\Widget\Dashboard\Pane; readonly class DashboardCspReason implements CspReason { /** + * @param Dashboard $dashboard the dashboard to load the CSP directive for * @param Pane $pane the pane that contains the dashlet * @param Dashlet $dashlet the dashlet to load the CSP directive for */ public function __construct( + public Dashboard $dashboard, public Pane $pane, public Dashlet $dashlet, ) { 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 eca4c4b75..7d2eb60fa 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -240,16 +240,17 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm 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 90cab3a0d..76d321196 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 @@ use Icinga\Web\Navigation\NavigationItem; */ class NavigationCspLoader implements CspLoader { + function __construct( + protected bool $allUsers = false, + ) { + } + /** * Fetches navigation items for the current user. * @@ -28,29 +35,84 @@ 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()); + } + } + } + } + + 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; diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index 5ac8576dc..09eaddfff 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -22,6 +22,7 @@ readonly class NavigationCspReason implements CspReason 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 | 51 +++++++++---------- 1 file changed, 25 insertions(+), 26 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 7d2eb60fa..abed963ee 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -7,7 +7,6 @@ namespace Icinga\Forms\Config\Security; 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 @@ class CspConfigForm extends CompatForm ), )); - $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm 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'; + $classList = [ + 'autosubmit', + 'csp-form-content-aligned', + 'csp-label-header-h4', + ]; - if ($field == null) { - $this->add(HtmlElement::create('h4', ['class' => "csp-form-hint $disabledClass"], $title)); - return; + 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 | 206 +++++++++++------- .../Csp/Reason/NavigationCspReason.php | 11 +- 3 files changed, 148 insertions(+), 90 deletions(-) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index abed963ee..624b4e32a 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -259,26 +259,25 @@ class CspConfigForm extends CompatForm 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 76d321196..a23618bb2 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 @@ use Icinga\Web\Navigation\NavigationItem; */ 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,74 +115,30 @@ class NavigationCspLoader implements CspLoader $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()); - } - } - } - } - - 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); + $result = array_merge($result, $this->loadConfig( + $type, + $typeConfig, + currentUser: $auth->getUser(), + )); + $result = array_merge($result, $this->loadConfig( + $type, + $typeConfig, + $username, + currentUser: $auth->getUser(), + )); } } @@ -119,25 +146,52 @@ class NavigationCspLoader implements CspLoader } /** - * Recursively yield all navigation items that have an external URL. + * Checks whether the user has access to a shared navigation item * - * @param NavigationItem $item The top-level navigation item to start from. - * @return Generator + * 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 static function yieldNavigation(NavigationItem $item): Generator + private function hasAccessToSharedNavigationItem(ConfigObject $config, Config $navConfig, User $user): bool { - if ($item->hasChildren()) { - foreach ($item as $child) { - yield from self::yieldNavigation($child); + if (isset($config['owner']) && strtolower($config['owner']) === strtolower($user->getUsername())) { + return true; + } + + if (isset($config['parent']) && $navConfig->hasSection($config['parent'])) { + $parentConfig = $navConfig->getSection($config['parent']); + return $this->hasAccessToSharedNavigationItem( + $parentConfig, + $navConfig, + $user, + ); + } + + if (isset($config['users'])) { + $users = array_map(trim(...), explode(',', strtolower($config['users']))); + if (in_array('*', $users, true) || in_array(strtolower($user->getUsername()), $users, true)) { + return true; } } - $url = $item->getUrl(); - if ($url === null) { - return; - } - if ($item->getTarget() !== '_blank' && $url->isExternal()) { - yield $item; + if (isset($config['groups'])) { + $groups = array_map(trim(...), explode(',', strtolower($config['groups']))); + if (in_array('*', $groups, true)) { + return true; + } + + $userGroups = array_map(strtolower(...), $user->getGroups()); + $matches = array_intersect($userGroups, $groups); + if (! empty($matches)) { + return true; + } } + + return false; } } diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index 09eaddfff..a2e10f62d 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -16,13 +16,18 @@ 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 + * @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 624b4e32a..0b60e281c 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -7,7 +7,6 @@ namespace Icinga\Forms\Config\Security; 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 @@ class CspConfigForm extends CompatForm 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 @@ class CspConfigForm extends CompatForm )); $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm } /** - * @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 @@ class CspConfigForm extends CompatForm * @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 c9ba26cc8..871bb0d3d 100644 --- a/library/Icinga/Security/Csp/LoadedCsp.php +++ b/library/Icinga/Security/Csp/LoadedCsp.php @@ -9,23 +9,13 @@ use Icinga\Security\Csp\Reason\CspReason; use ipl\Web\Common\Csp; /** - * A CSP that has been loaded from a source. - * Contains the reason for the CSP directive/policy to exist. + * 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 0c5ef8adf..ffabb5374 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -13,6 +13,7 @@ use Icinga\Security\Csp\Reason\DashboardCspReason; use Icinga\User; use Icinga\Web\Url; use Icinga\Web\Widget\Dashboard; +use ipl\Web\Common\Csp; /** * This loader is responsible for loading CSP directives for external URLs in dashboard panes. @@ -66,9 +67,9 @@ class DashboardCspLoader implements CspLoader $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 @@ class DashboardCspLoader implements CspLoader } 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 f4c9cb2f2..7531a72a7 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -35,7 +35,7 @@ class ModuleCspLoader implements CspLoader 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 a23618bb2..208e270d0 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\LoadedCsp; 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 @@ class NavigationCspLoader implements CspLoader $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 @@ class NavigationCspLoader implements CspLoader $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 6146ec4db..f94a258cf 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -7,6 +7,7 @@ namespace Icinga\Security\Csp\Loader; 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 @@ class StaticCspLoader implements CspLoader 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 5c1690c3d..77b382a04 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -9,7 +9,6 @@ use Exception; use Icinga\Application\Config; use Icinga\Application\Icinga; use Icinga\Application\Logger; -use Icinga\Data\ConfigObject; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Loader\DashboardCspLoader; use Icinga\Security\Csp\Loader\ModuleCspLoader; @@ -33,7 +32,7 @@ use RuntimeException; */ class Csp { - /** @var CspInstance|null */ + /** @var ?CspInstance */ protected static ?CspInstance $csp = null; /** Singleton */ @@ -125,58 +124,23 @@ class Csp 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 @@ class Csp $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 @@ class Csp */ 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 ffabb5374..3d2461ed1 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -90,11 +90,14 @@ class DashboardCspLoader implements CspLoader 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 208e270d0..a82c9e2cf 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -117,12 +117,14 @@ class NavigationCspLoader implements CspLoader 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 0b60e281c..58e453bdb 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -7,7 +7,7 @@ namespace Icinga\Forms\Config\Security; 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 @@ class CspConfigForm extends CompatForm } /** - * @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 @@ class CspConfigForm extends CompatForm * @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 871bb0d3d..f1db6dca5 100644 --- a/library/Icinga/Security/Csp/LoadedCsp.php +++ b/library/Icinga/Security/Csp/AttributedCsp.php @@ -9,13 +9,13 @@ use Icinga\Security\Csp\Reason\CspReason; 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 6fe3f6bdb..3d75a2ef8 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 3d2461ed1..96045005f 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -8,7 +8,7 @@ namespace Icinga\Security\Csp\Loader; 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 @@ class DashboardCspLoader implements CspLoader /** * @param User $user * - * @return LoadedCsp[] + * @return AttributedCsp[] */ protected function loadForUser(User $user): array { @@ -69,7 +69,7 @@ class DashboardCspLoader implements CspLoader $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 @@ class DashboardCspLoader implements CspLoader /** * Fetches all dashlets for the current user that have an external URL. * - * @return LoadedCsp[] A list of CSP directives, one for each dashlet that has an external URL. + * @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 7531a72a7..acc4481f1 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -8,7 +8,7 @@ namespace Icinga\Security\Csp\Loader; 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 @@ class ModuleCspLoader implements CspLoader 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 a82c9e2cf..632b404c6 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -9,7 +9,7 @@ use DirectoryIterator; 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 @@ class NavigationCspLoader implements CspLoader * @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 @@ class NavigationCspLoader implements CspLoader $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 @@ class NavigationCspLoader implements CspLoader * 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 f94a258cf..b6f0fe124 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 @@ class StaticCspLoader implements CspLoader $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 77b382a04..c8ed11b12 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -9,7 +9,7 @@ use Exception; 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 @@ class Csp 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 @@ class Csp */ 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 @@ class Csp 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 @@ class Csp 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 58e453bdb..de8f335a4 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -185,7 +185,7 @@ class CspConfigForm extends CompatForm ); $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 @@ class CspConfigForm extends CompatForm ); $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 @@ class CspConfigForm extends CompatForm ); $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 f1e7ef050..14581178b 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 269b3a5f9..3ee9f1f1e 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 3d75a2ef8..8d1838dcc 100644 --- a/library/Icinga/Security/Csp/Loader/CspLoader.php +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -14,9 +14,12 @@ use Icinga\Security\Csp\AttributedCsp; 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 96045005f..863d78b1a 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -23,14 +23,6 @@ use ipl\Web\Common\Csp; */ 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 @@ class DashboardCspLoader implements CspLoader 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 acc4481f1..9cf092f7c 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -19,19 +19,13 @@ use Throwable; */ 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 632b404c6..a529b4ee4 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -22,11 +22,6 @@ use ipl\Web\Url; */ 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 @@ class NavigationCspLoader implements CspLoader 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 @@ class NavigationCspLoader implements CspLoader $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 @@ class NavigationCspLoader implements CspLoader /** * 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 b6f0fe124..0b1359afc 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -26,7 +26,7 @@ class StaticCspLoader implements CspLoader ) { } - 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 de8f335a4..3e130ea85 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -163,7 +163,7 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm 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 @@ class CspConfigForm extends CompatForm } $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 @@ class CspConfigForm extends CompatForm } $this->addElement('submit', 'submit', [ - 'label' => t('Save changes'), + 'label' => $this->translate('Save changes'), ]); } @@ -363,7 +376,7 @@ class CspConfigForm extends CompatForm 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 @@ class CspConfigForm extends CompatForm '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 @@ class CspConfigForm extends CompatForm '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 @@ class CspConfigForm extends CompatForm '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 @@ class CspConfigForm extends CompatForm '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 3e130ea85..1c7af79b1 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -32,12 +32,19 @@ use ipl\Web\Widget\Callout; use ipl\Web\Widget\Icon; use ipl\Web\Widget\Link; +/** + * Configuration form for CSP + * + * This form is used to configure the CSP-Header. It is used to enable or + * disable CSP, configure the allowed sources for automatic generation or to + * specify a custom CSP-Header. + */ class CspConfigForm extends CompatForm { use FormUid; use CsrfCounterMeasure; - /** @var string[] */ + /** @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 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm } /** - * @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 @@ class CspConfigForm extends CompatForm )); } + /** + * 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 @@ class CspConfigForm extends CompatForm 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 @@ class CspConfigForm extends CompatForm 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 3ee9f1f1e..23d63676f 100644 --- a/library/Icinga/Application/Hook/CspHook.php +++ b/library/Icinga/Application/Hook/CspHook.php @@ -15,13 +15,13 @@ use ipl\Web\Common\Csp; 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 863d78b1a..ef4088055 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -19,12 +19,14 @@ use ipl\Web\Common\Csp; * This loader is responsible for loading CSP directives for external URLs in dashboard panes. * It iterates through all dashboard panes and checks if any dashlets have an external URL. * If an external URL is found, it adds a CSP directive for the URL's host and port. - * The CSP directive allows the iframe to be embedded on the page.' + * 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 0b1359afc..c206e99a5 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -16,8 +16,8 @@ use ipl\Web\Common\Csp; 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 ca9a9ff55..a3d323e7c 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 ae80f9438..c06e250a4 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\Dashlet; use Icinga\Web\Widget\Dashboard\Pane; /** - * Reason for loading a CSP directive for a dashboard dashlet. - * The CSP directive allows the iframe to be embedded on the page. + * 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 3e16b3ecb..83207ccc0 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 a2e10f62d..ddd05675d 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 1c7af79b1..57f824161 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -137,11 +137,15 @@ class CspConfigForm extends CompatForm ); $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 @@ class CspConfigForm extends CompatForm } 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 @@ class CspConfigForm extends CompatForm $this->add(HtmlElement::create( 'h4', - ['class' => "csp-form-hint $disabledClass"], + ['class' => $formHintClassList], $this->translate('System'), )); @@ -468,8 +472,13 @@ class CspConfigForm extends CompatForm 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 57f824161..a2ee0e96b 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -275,20 +275,26 @@ class CspConfigForm extends CompatForm } 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 ddd05675d..cb892b942 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -17,7 +17,7 @@ readonly class NavigationCspReason implements CspReason * @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 @@ readonly class NavigationCspReason implements CspReason 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 a2ee0e96b..d6ecb4efa 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -136,10 +136,10 @@ class CspConfigForm extends CompatForm ], ); - $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 @@ class CspConfigForm extends CompatForm $this->buildExpression($directive, $expression), ]); }, - ! $disabledState, + ! $useCustomCsp, $this->translate('No system policies defined.') ); @@ -198,7 +198,7 @@ class CspConfigForm extends CompatForm . ' Note: Modules can define or change csp directives at any point.' ), 'csp_enable_modules', - ! $disabledState, + ! $useCustomCsp, ); $this->addDirectiveContentElement( @@ -211,7 +211,7 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm . ' matters to them.' ), 'csp_enable_dashboards', - ! $disabledState, + ! $useCustomCsp, ); $this->addDirectiveContentElement( @@ -244,7 +244,7 @@ class CspConfigForm extends CompatForm $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 @@ class CspConfigForm extends CompatForm . ' directives that actually matters to them.' ), 'csp_enable_navigation', - ! $disabledState, + ! $useCustomCsp, ); $this->addDirectiveContentElement( @@ -299,7 +299,7 @@ class CspConfigForm extends CompatForm $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 d6ecb4efa..dee0b9b12 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -373,10 +373,9 @@ class CspConfigForm extends CompatForm $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 43e367fc3..40ace4f67 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -8,7 +8,6 @@ namespace Icinga\Controllers; use Exception; use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; -use Icinga\Util\Csp; use InvalidArgumentException; use Icinga\Application\Config; use Icinga\Application\Icinga; @@ -91,6 +90,8 @@ class ConfigController extends Controller { if ($this->hasPermission('config/general')) { $this->redirectNow('config/general'); + } elseif ($this->hasPermission('config/security')) { + $this->redirectNow('config/security'); } elseif ($this->hasPermission('config/resources')) { $this->redirectNow('config/resource'); } elseif ($this->hasPermission('config/access-control/*')) { 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 dee0b9b12..7fbb3a309 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -657,7 +657,9 @@ class CspConfigForm extends CompatForm '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.' + ), ], ), ]