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

translate('General') ?>

+

translate('Content Security Policy') ?>

+
diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php new file mode 100644 index 0000000000..fb0e243de6 --- /dev/null +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -0,0 +1,47 @@ +getAttributes()->add('class', 'csp-config-table'); + } + + protected function assemble(): void + { + $this->add(self::tr([ + self::th($this->translate('Type')), + self::th($this->translate('Info')), + self::th($this->translate('Directive')), + self::th($this->translate('Value')), + ])); + + $policyDirectives = Csp::collectContentSecurityPolicyDirectives(); + + foreach ($policyDirectives as $directiveGroup) { + $reason = $directiveGroup['reason']; + $type = $reason['type']; + $info = match ($type) { + 'dashlet' => $reason['pane'] . '/' . $reason['dashlet'], + 'hook' => $reason['hook'], + default => '-', + }; + foreach ($directiveGroup['directives'] as $directive => $policies) { + $this->add(self::tr([ + self::td($type), + self::td($info), + self::td($directive), + self::td(join(', ', $policies)), + ])); + } + } + } +} diff --git a/public/css/icinga/widgets.less b/public/css/icinga/widgets.less index c482f0ce5e..fa3dcf0c68 100644 --- a/public/css/icinga/widgets.less +++ b/public/css/icinga/widgets.less @@ -665,3 +665,8 @@ ul.tree li a.error:hover { html.no-js .progress-label { display: none; } + +.csp-config-table { + width: 80%; + max-width: 70em; +} From ba93f40817d804c6deceacbda5e6e10935eb8dad 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/83] Integrate the custom CSP setting --- library/Icinga/Util/Csp.php | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 054fed5dfe..7632cd66c5 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -142,6 +142,8 @@ public static function collectContentSecurityPolicyDirectives(): array ]; } + $policyDirectives = array_merge($policyDirectives, self::fetchCustomCspDirectives()); + return $policyDirectives; } @@ -173,6 +175,9 @@ public static function getContentSecurityPolicy(): string foreach ($policyDirectives as $directive) { foreach ($directive['directives'] as $directive => $policies) { + if (! isset($cspDirectives[$directive])) { + $cspDirectives[$directive] = []; + } $cspDirectives[$directive] = array_merge($cspDirectives[$directive], $policies); } } @@ -240,7 +245,37 @@ protected static function getInstance(): self return static::$instance; } - + + public static function fetchCustomCspDirectives(): array + { + $config = Config::app(); + $setting = $config->get('security', 'custom_csp'); + + if ($setting === null) { + return []; + } + + $menuDirectives = []; + + $sections = explode(';', $setting); + foreach ($sections as $section) { + $parts = explode(' ', trim($section)); + if (count ($parts) < 2) { + continue; + } + $directive = array_shift($parts); + $menuDirectives[] = [ + 'directives' => [ + $directive => $parts, + ], + 'reason' => [ + 'type' => 'custom', + ], + ]; + } + + return $menuDirectives; + } /** * Fetches and merges configurations for navigation menu items and dashlets. From c0ed797a2ba9090628146d42f6a70050fc01c3cd 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/83] Use new hook style --- .../Application/Hook/CspDirectiveHook.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspDirectiveHook.php index 43eb6c26eb..451248be73 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspDirectiveHook.php @@ -3,6 +3,8 @@ namespace Icinga\Application\Hook; +use Icinga\Application\Hook; + abstract class CspDirectiveHook { /** @@ -15,4 +17,24 @@ abstract class CspDirectiveHook * @return array The CSP directives are the keys and the policies the values. */ abstract public function getCspDirectives(): array; + + /** + * Get all registered implementations + * + * @return static[] + */ + public static function all(): array + { + return Hook::all('CspDirective'); + } + + /** + * Register the class as a RequestHook implementation + * + * Call this method on your implementation during module initialization to make Icinga Web aware of your hook. + */ + public static function register(): void + { + Hook::register('CspDirective', static::class, static::class, true); + } } From 53a2097959e178b7a4503901ffb5ec283f616b81 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/83] Custom CSP should completely override the automatically generated one --- library/Icinga/Util/Csp.php | 125 ++++++++++++++++-------------------- 1 file changed, 54 insertions(+), 71 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 7632cd66c5..556aaa91a9 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -19,6 +19,7 @@ use Icinga\Web\Navigation\Navigation; use Icinga\Web\Widget\Dashboard; +use Throwable; use function ipl\Stdlib\get_php_type; /** @@ -95,66 +96,78 @@ public static function collectContentSecurityPolicyDirectives(): array ], 'reason' => $navigationItem['reason'], ]; -// -// $cspDirectives['frame-src'][] = $policy; } + // Allow modules to add their own csp directives in a limited fashion. - /** @var CspDirectiveHook $hook */ - foreach (Hook::all('CspDirective') as $hook) { + foreach (CspDirectiveHook::all() as $hook) { $directives = []; - foreach ($hook->getCspDirectives() as $directive => $policies) { - // policy names contain only lowercase letters and '-'. Reject anything else. - if (!preg_match('|^[a-z\-]+$|', $directive)) { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Invalid CSP directive found: $directive"); - continue; - } - - // The default-src can only ever be 'self'. Disallow any updates to it. - if ($directive === "default-src") { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Changing default-src is forbidden."); - continue; + try { + foreach ($hook->getCspDirectives() as $directive => $policies) { + // policy names contain only lowercase letters and '-'. Reject anything else. + if (! preg_match('|^[a-z\-]+$|', $directive)) { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Invalid CSP directive found: $directive"); + continue; + } + + // The default-src can only ever be 'self'. Disallow any updates to it. + if ($directive === "default-src") { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Changing default-src is forbidden."); + continue; + } + + if (count($policies) === 0) { + continue; + } + + $directives[$directive] = $policies; } -// $cspDirectives[$directive] = $cspDirectives[$directive] ?? []; -// foreach ($policies as $policy) { -// $cspDirectives[$directive][] = $policy; -// } - - if (count($policies) === 0) { + if (count($directives) === 0) { continue; } - $directives[$directive] = $policies; - } - - if (count($directives) === 0) { - continue; + $policyDirectives[] = [ + "directives" => $directives, + "reason" => [ + "type" => "hook", + "hook" => get_class($hook), + ], + ]; + } catch (Throwable $e) { + Logger::error('Failed to CSP hook on request: %s', $e); } - - $policyDirectives[] = [ - "directives" => $directives, - "reason" => [ - "type" => "hook", - "hook" => get_class($hook), - ], - ]; } - $policyDirectives = array_merge($policyDirectives, self::fetchCustomCspDirectives()); - return $policyDirectives; } /** - * Get the Content-Security-Policy for a specific user. + * Get the Content-Security-Policy. * * @throws RuntimeException If no nonce set for CSS * * @return string Returns the generated header value. */ public static function getContentSecurityPolicy(): string + { + $config = Config::app(); + if ($config->get('security', 'use_custom_csp', 'y') === 'y') { + return $config->get('security', 'custom_csp', ''); + } + + return self::getAutomaticContentSecurityPolicy(); + } + + /** + * Get the automatically generated Content-Security-Policy. + * + * @throws RuntimeException If no nonce set for CSS + * + * @return string Returns the generated header value. + */ + public static function getAutomaticContentSecurityPolicy(): string { $csp = static::getInstance(); @@ -163,7 +176,7 @@ public static function getContentSecurityPolicy(): string } // These are the default directives that should always be enforced. 'self' is valid for all - // directives and will therefor not be listed here. + // directives and will therefore not be listed here. $cspDirectives = [ 'style-src' => ["'nonce-{$csp->styleNonce}'"], 'font-src' => ["data:"], @@ -191,6 +204,7 @@ public static function getContentSecurityPolicy(): string return $header; } + /** * Set/recreate nonce for dynamic CSS * @@ -246,37 +260,6 @@ protected static function getInstance(): self return static::$instance; } - public static function fetchCustomCspDirectives(): array - { - $config = Config::app(); - $setting = $config->get('security', 'custom_csp'); - - if ($setting === null) { - return []; - } - - $menuDirectives = []; - - $sections = explode(';', $setting); - foreach ($sections as $section) { - $parts = explode(' ', trim($section)); - if (count ($parts) < 2) { - continue; - } - $directive = array_shift($parts); - $menuDirectives[] = [ - 'directives' => [ - $directive => $parts, - ], - 'reason' => [ - 'type' => 'custom', - ], - ]; - } - - return $menuDirectives; - } - /** * Fetches and merges configurations for navigation menu items and dashlets. * From 6a0e7d8d4821937b971de5399955a3737a614f7d 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/83] Allow configuration of the custom CSP-Header --- application/controllers/ConfigController.php | 4 +- .../forms/Config/General/CspConfigForm.php | 41 +++++++++++++++++-- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 235c21925d..145ccdd82e 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -108,11 +108,11 @@ public function generalAction() $this->view->form = $form; - $cspForm = new CspConfigForm(); $config = Config::app(); + $cspForm = new CspConfigForm($config); $cspForm->populate([ 'use_strict_csp' => $config->get('security', 'use_strict_csp'), - 'custom_csp' => $config->get('security', 'custom_csp'), + 'use_custom_csp' => $config->get('security', 'use_custom_csp'), ]); $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 2cd285f6a0..0e59232390 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,8 +14,11 @@ class CspConfigForm extends CompatForm use FormUid; use CsrfCounterMeasure; - public function __construct() + protected Config $config; + + public function __construct(Config $config) { + $this->config = $config; $this->setAttribute("name", "csp_config"); $this->applyDefaultElementDecorators(); } @@ -37,14 +41,44 @@ protected function assemble(): void ], ); + $this->addElement( + 'checkbox', + 'use_custom_csp', + [ + 'label' => $this->translate('Enable Custom CSP'), + 'description' => $this->translate( + 'Specify whether to use a custom, user provided, string as the CSP-Header.' + . ' If you decide to provide your own CSP-Header, you are entirely responsible for keeping it' + . ' up-to-date.' + ), + 'class' => 'autosubmit', + ] + ); + + $this->addElement('hidden', 'hidden_custom_csp'); + + $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; $this->addElement('textarea', 'custom_csp', [ 'label' => 'Custom CSP', 'description' => $this->translate( - 'Set custom CSP directives. These values are parsed and merged with the values supplied by modules' - . ' and navigation items.' + 'Set a custom CSP-Header. This completely overrides the automatically generated one.' ), + 'disabled' => ! $useCustomCsp, ]); + $customCspElement = $this->getElement('custom_csp'); + if ($useCustomCsp) { + $value = $this->getPopulatedValue('hidden_custom_csp'); + if (! empty($value)) { + $customCspElement->setValue($value); + } else { + $customCspElement->setValue($this->config->get('security', 'custom_csp')); + } + } else { + $this->getElement('hidden_custom_csp')->setValue($this->getValue('custom_csp')); + $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); + } + $this->addElement('submit', 'submit', [ 'label' => t('Save changes'), ]); @@ -56,6 +90,7 @@ protected function onSuccess(): void $section = $config->getSection('security'); $section['use_strict_csp'] = $this->getValue('use_strict_csp'); + $section['use_custom_csp'] = $this->getValue('use_custom_csp'); $section['custom_csp'] = $this->getValue('custom_csp'); $config->setSection('security', $section); From 332cbead3f670d210e5dd7b86da1d331e290a2c2 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/83] Move the check to send the CSP header into the Csp::isCspEnabled method --- library/Icinga/Util/Csp.php | 21 ++++++++++++--------- library/Icinga/Web/Response.php | 2 +- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 556aaa91a9..44716c4bd9 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -5,13 +5,11 @@ namespace Icinga\Util; -use Icinga\Application\Hook; use Icinga\Application\Hook\CspDirectiveHook; use Icinga\Application\Icinga; use Icinga\Application\Logger; use Icinga\Authentication\Auth; use Icinga\Data\ConfigObject; -use Icinga\User; use Icinga\Web\Response; use Icinga\Web\Window; use Icinga\Application\Config; @@ -35,11 +33,11 @@ */ class Csp { - /** @var static */ - protected static $instance; + /** @var self|null */ + protected static ?self $instance = null; /** @var ?string */ - protected $styleNonce; + protected ?string $styleNonce = null; /** Singleton */ private function __construct() @@ -47,7 +45,7 @@ private function __construct() } /** - * Add Content-Security-Policy header with a nonce for dynamic CSS + * Add a Content-Security-Policy header with a nonce for dynamic CSS * * Note that {@see static::createNonce()} must be called beforehand. * @@ -61,6 +59,11 @@ public static function addHeader(Response $response): void $response->setHeader('Content-Security-Policy', $header, true); } + public static function isCspEnabled(): bool + { + return Config::app()->get('security', 'use_strict_csp', 'n') === 'y'; + } + public static function collectContentSecurityPolicyDirectives(): array { $policyDirectives = []; @@ -266,7 +269,7 @@ protected static function getInstance(): self * @return array An array containing both navigation items and dashlet configurations. * // returns [['name' => 'Item Name', 'url' => 'https://example.com'], ...] */ - protected static function fetchDashletNavigationItemConfigs() + protected static function fetchDashletNavigationItemConfigs(): array { return array_merge( self::fetchNavigationItems(), @@ -283,7 +286,7 @@ protected static function fetchDashletNavigationItemConfigs() * @return array Each item is an associative array with 'name' and 'url' keys. * Example: [ ['name' => 'Home', 'url' => '/'], ['name' => 'Profile', 'url' => '/profile'] ] */ - protected static function fetchNavigationItems() + protected static function fetchNavigationItems(): array { $user = Auth::getInstance()->getUser(); $menuItems = []; @@ -335,7 +338,7 @@ protected static function fetchNavigationItems() * @return array A list of dashlets with their names and absolute URLs. * // returns [['name' => 'Dashlet Name', 'url' => 'https://external.dashlet.com'], ...] */ - protected static function fetchDashletsItems() + protected static function fetchDashletsItems(): array { $user = Auth::getInstance()->getUser(); $dashlets = []; diff --git a/library/Icinga/Web/Response.php b/library/Icinga/Web/Response.php index 19c25ddbb6..7a9e6033b7 100644 --- a/library/Icinga/Web/Response.php +++ b/library/Icinga/Web/Response.php @@ -383,7 +383,7 @@ protected function prepare() $this->setRedirect($redirectUrl->getAbsoluteUrl()); } - if (Csp::getStyleNonce() && Config::app()->get('security', 'use_strict_csp', false)) { + if (Csp::getStyleNonce() && Csp::isCspEnabled()) { Csp::addHeader($this); } } From 4586f2fc9e01434c790d974605a33730c1633732 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/83] 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 0e59232390..dda00f322b 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -59,14 +59,25 @@ protected function assemble(): void $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; $this->addElement('textarea', 'custom_csp', [ - 'label' => 'Custom CSP', + 'label' => $useCustomCsp ? $this->translate('Custom CSP') : $this->translate('Generated CSP'), 'description' => $this->translate( 'Set a custom CSP-Header. This completely overrides the automatically generated one.' ), 'disabled' => ! $useCustomCsp, ]); + $this->addElement('submit', 'submit', [ + 'label' => t('Save changes'), + ]); + $customCspElement = $this->getElement('custom_csp'); + if ($this->hasBeenSubmitted()) { + if (! $useCustomCsp) { + $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); + } + return; + } + if ($useCustomCsp) { $value = $this->getPopulatedValue('hidden_custom_csp'); if (! empty($value)) { @@ -78,10 +89,6 @@ protected function assemble(): void $this->getElement('hidden_custom_csp')->setValue($this->getValue('custom_csp')); $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); } - - $this->addElement('submit', 'submit', [ - 'label' => t('Save changes'), - ]); } protected function onSuccess(): void @@ -91,7 +98,10 @@ protected function onSuccess(): void $section = $config->getSection('security'); $section['use_strict_csp'] = $this->getValue('use_strict_csp'); $section['use_custom_csp'] = $this->getValue('use_custom_csp'); - $section['custom_csp'] = $this->getValue('custom_csp'); + $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; + if ($useCustomCsp) { + $section['custom_csp'] = $this->getPopulatedValue('custom_csp'); + } $config->setSection('security', $section); $config->saveIni(); From f692db53878cd16dddc77ab5bc7684164bd402ee 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/83] Allow for the usage of {style_nonce} in the custom CSP-Header setting --- library/Icinga/Util/Csp.php | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 44716c4bd9..a82c9cb679 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -157,12 +157,26 @@ public static function getContentSecurityPolicy(): string { $config = Config::app(); if ($config->get('security', 'use_custom_csp', 'y') === 'y') { - return $config->get('security', 'custom_csp', ''); + return self::getCustomContentSecurityPolicy(); } return self::getAutomaticContentSecurityPolicy(); } + public static function getCustomContentSecurityPolicy(): ?string + { + $csp = static::getInstance(); + + if (empty($csp->styleNonce)) { + throw new RuntimeException('No nonce set for CSS'); + } + + $config = Config::app(); + $raw = $config->get('security', 'custom_csp'); + $formated = str_replace('{style_nonce}', "'nonce{$csp->styleNonce}'", $raw); + return $formated; + } + /** * Get the automatically generated Content-Security-Policy. * From 9569d07830250b00bd72e518a663fdbb3f8a3f83 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/83] Allow newlines in custom CSP --- library/Icinga/Util/Csp.php | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index a82c9cb679..ea8c5abfd5 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -163,7 +163,7 @@ public static function getContentSecurityPolicy(): string return self::getAutomaticContentSecurityPolicy(); } - public static function getCustomContentSecurityPolicy(): ?string + protected static function getCustomContentSecurityPolicy(): ?string { $csp = static::getInstance(); @@ -172,9 +172,11 @@ public static function getCustomContentSecurityPolicy(): ?string } $config = Config::app(); - $raw = $config->get('security', 'custom_csp'); - $formated = str_replace('{style_nonce}', "'nonce{$csp->styleNonce}'", $raw); - return $formated; + $customCsp = $config->get('security', 'custom_csp'); + $customCsp = str_replace("\r\n", ' ', $customCsp); + $customCsp = str_replace("\n", ' ', $customCsp); + $customCsp = str_replace('{style_nonce}', "'nonce-{$csp->styleNonce}'", $customCsp); + return $customCsp; } /** From ccd79d84247e58b75f71ce5e1d837db48136563f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 13:44:32 +0100 Subject: [PATCH 16/83] Fix a bug that caused the custom_csp value to not be saved --- 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 dda00f322b..d92a26e736 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -100,7 +100,7 @@ protected function onSuccess(): void $section['use_custom_csp'] = $this->getValue('use_custom_csp'); $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; if ($useCustomCsp) { - $section['custom_csp'] = $this->getPopulatedValue('custom_csp'); + $section['custom_csp'] = $this->getValue('custom_csp'); } $config->setSection('security', $section); From e8e40eb2ed66e51a9cb9da57ba352693899282cb 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 17/83] 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 d92a26e736..d2b865ca04 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -58,13 +58,25 @@ protected function assemble(): void $this->addElement('hidden', 'hidden_custom_csp'); $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; - $this->addElement('textarea', 'custom_csp', [ - 'label' => $useCustomCsp ? $this->translate('Custom CSP') : $this->translate('Generated CSP'), - 'description' => $this->translate( - 'Set a custom CSP-Header. This completely overrides the automatically generated one.' - ), - 'disabled' => ! $useCustomCsp, - ]); + if ($useCustomCsp) { + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate('Custom CSP'), + 'description' => $this->translate( + 'Set a custom CSP-Header. This completely overrides the automatically generated one.' + . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.' + ), + 'disabled' => false, + ]); + } else { + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate('Generated CSP'), + 'description' => $this->translate( + 'This is the current CSP-Header. You can always safely go back to this by disabling the' + . ' Enable Custom CSP checkbox above.' + ), + 'disabled' => true, + ]); + } $this->addElement('submit', 'submit', [ 'label' => t('Save changes'), From 473baac958ceee5c3646c8d5c22881598385ba41 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 18/83] Fix code formating --- library/Icinga/Util/Csp.php | 142 ++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 71 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index ea8c5abfd5..97136c492a 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -5,18 +5,17 @@ namespace Icinga\Util; +use Icinga\Application\Config; use Icinga\Application\Hook\CspDirectiveHook; use Icinga\Application\Icinga; use Icinga\Application\Logger; use Icinga\Authentication\Auth; use Icinga\Data\ConfigObject; +use Icinga\Web\Navigation\Navigation; use Icinga\Web\Response; +use Icinga\Web\Widget\Dashboard; use Icinga\Web\Window; -use Icinga\Application\Config; use RuntimeException; -use Icinga\Web\Navigation\Navigation; -use Icinga\Web\Widget\Dashboard; - use Throwable; use function ipl\Stdlib\get_php_type; @@ -97,7 +96,7 @@ public static function collectContentSecurityPolicyDirectives(): array 'directives' => [ 'frame-src' => [$policy], ], - 'reason' => $navigationItem['reason'], + 'reason' => $navigationItem['reason'], ]; } @@ -149,9 +148,9 @@ public static function collectContentSecurityPolicyDirectives(): array /** * Get the Content-Security-Policy. * + * @return string Returns the generated header value. * @throws RuntimeException If no nonce set for CSS * - * @return string Returns the generated header value. */ public static function getContentSecurityPolicy(): string { @@ -182,9 +181,9 @@ protected static function getCustomContentSecurityPolicy(): ?string /** * Get the automatically generated Content-Security-Policy. * + * @return string Returns the generated header value. * @throws RuntimeException If no nonce set for CSS * - * @return string Returns the generated header value. */ public static function getAutomaticContentSecurityPolicy(): string { @@ -198,9 +197,9 @@ public static function getAutomaticContentSecurityPolicy(): string // directives and will therefore not be listed here. $cspDirectives = [ 'style-src' => ["'nonce-{$csp->styleNonce}'"], - 'font-src' => ["data:"], - 'img-src' => ["data:"], - 'frame-src' => [] + 'font-src' => ["data:"], + 'img-src' => ["data:"], + 'frame-src' => [], ]; $policyDirectives = self::collectContentSecurityPolicyDirectives(); @@ -216,11 +215,13 @@ public static function getAutomaticContentSecurityPolicy(): string $header = "default-src 'self'; "; foreach ($cspDirectives as $directive => $policyDirectives) { - if (!empty($policyDirectives)) { - $header .= ' ' . implode(' ', array_merge([$directive, "'self'"], array_unique($policyDirectives))) . ';'; + if (! empty($policyDirectives)) { + $header .= ' ' . + implode(' ', array_merge([$directive, "'self'"], array_unique($policyDirectives))) + . ';'; } } - + return $header; } @@ -266,8 +267,8 @@ protected static function getInstance(): self throw new RuntimeException( sprintf( 'Nonce value is expected to be string, got %s instead', - get_php_type($nonce) - ) + get_php_type($nonce), + ), ); } @@ -289,7 +290,7 @@ protected static function fetchDashletNavigationItemConfigs(): array { return array_merge( self::fetchNavigationItems(), - self::fetchDashletsItems() + self::fetchDashletsItems(), ); } @@ -316,13 +317,13 @@ protected static function fetchNavigationItems(): array foreach ($config->select() as $itemConfig) { if ($itemConfig->get("target", "") !== "_blank") { $menuItems[] = [ - "name" => $itemConfig->get('name'), - "url" => $itemConfig->get('url'), + "name" => $itemConfig->get('name'), + "url" => $itemConfig->get('url'), "reason" => [ - 'type' => 'navigation', - 'name' => $itemConfig->get('name'), + 'type' => 'navigation', + 'name' => $itemConfig->get('name'), 'shared' => false, - ] + ], ]; } } @@ -334,13 +335,13 @@ protected static function fetchNavigationItems(): array $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 +357,55 @@ protected static function fetchNavigationItems(): array */ protected static function fetchDashletsItems(): array { - $user = Auth::getInstance()->getUser(); - $dashlets = []; - if ($user === null) { - return $dashlets; - } - - $dashboard = new Dashboard(); - $dashboard->setUser($user); - $dashboard->load(); - - foreach ($dashboard->getPanes() as $pane) { - foreach ($pane->getDashlets() as $dashlet) { - $url = $dashlet->getUrl(); - if ($url === null) { - continue; - } - - $externalUrl = $url->getParam("url"); - if ($externalUrl !== null && filter_var($externalUrl, FILTER_VALIDATE_URL) !== false) { - $dashlets[] = [ - "name" => $dashlet->getName(), - "url" => $externalUrl, - "reason" => [ - "type" => "dashlet", - "user" => $user->getUsername(), - "pane" => $pane->getName(), - "dashlet" => $dashlet->getName(), - ], - ]; - continue; - } + $user = Auth::getInstance()->getUser(); + $dashlets = []; + if ($user === null) { + return $dashlets; + } + + $dashboard = new Dashboard(); + $dashboard->setUser($user); + $dashboard->load(); + + foreach ($dashboard->getPanes() as $pane) { + foreach ($pane->getDashlets() as $dashlet) { + $url = $dashlet->getUrl(); + if ($url === null) { + continue; + } - if ($url->isExternal()) { - $absoluteUrl = $url->getAbsoluteUrl(); - if (filter_var($absoluteUrl, FILTER_VALIDATE_URL) !== false) { + $externalUrl = $url->getParam("url"); + if ($externalUrl !== null && filter_var($externalUrl, FILTER_VALIDATE_URL) !== false) { $dashlets[] = [ - "name" => $dashlet->getName(), - "url" => $absoluteUrl, - "reason" => [ - "type" => "dashlet-iframe", - "user" => $user->getUsername(), - "pane" => $pane->getName(), + "name" => $dashlet->getName(), + "url" => $externalUrl, + "reason" => [ + "type" => "dashlet", + "user" => $user->getUsername(), + "pane" => $pane->getName(), "dashlet" => $dashlet->getName(), ], ]; + continue; } - } - } - } - return $dashlets; - } + if ($url->isExternal()) { + $absoluteUrl = $url->getAbsoluteUrl(); + if (filter_var($absoluteUrl, FILTER_VALIDATE_URL) !== false) { + $dashlets[] = [ + "name" => $dashlet->getName(), + "url" => $absoluteUrl, + "reason" => [ + "type" => "dashlet-iframe", + "user" => $user->getUsername(), + "pane" => $pane->getName(), + "dashlet" => $dashlet->getName(), + ], + ]; + } + } + } + } + return $dashlets; + } } From 2cf104b24d21d4d8010edcaf7a7c828baef03f45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 14:17:25 +0100 Subject: [PATCH 19/83] fixup! Fix code formating --- library/Icinga/Util/Csp.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 97136c492a..a2fd230752 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -330,8 +330,7 @@ protected static function fetchNavigationItems(): array $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[] = [ From 00cd765b3f596d02cbbd9c41cb014c47972cfe8d 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 20/83] Use generator to iterate the navigation items --- library/Icinga/Util/Csp.php | 70 ++++++++++++++++++++----------------- 1 file changed, 37 insertions(+), 33 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index a2fd230752..0e0444c0b8 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -5,6 +5,7 @@ namespace Icinga\Util; +use Generator; use Icinga\Application\Config; use Icinga\Application\Hook\CspDirectiveHook; use Icinga\Application\Icinga; @@ -12,6 +13,7 @@ use Icinga\Authentication\Auth; use Icinga\Data\ConfigObject; use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Navigation\NavigationItem; use Icinga\Web\Response; use Icinga\Web\Widget\Dashboard; use Icinga\Web\Window; @@ -301,58 +303,60 @@ protected static function fetchDashletNavigationItemConfigs(): array * and shared configurations, and returns a list of menu items. * * @return array Each item is an associative array with 'name' and 'url' keys. - * Example: [ ['name' => 'Home', 'url' => '/'], ['name' => 'Profile', 'url' => '/profile'] ] + * Example: [ ['name' => 'Home', 'url' => '/', 'reason' => [...] ], ... ] */ protected static function fetchNavigationItems(): array { - $user = Auth::getInstance()->getUser(); - $menuItems = []; - if ($user === null) { - return $menuItems; + $auth = Auth::getInstance(); + if (! $auth->isAuthenticated()) { + return []; } + + $origins = []; $navigationType = Navigation::getItemTypeConfiguration(); foreach ($navigationType as $type => $_) { - $config = Config::navigation($type, $user->getUsername()); - $config->getConfigObject()->setKeyColumn('name'); - foreach ($config->select() as $itemConfig) { - if ($itemConfig->get("target", "") !== "_blank") { - $menuItems[] = [ - "name" => $itemConfig->get('name'), - "url" => $itemConfig->get('url'), - "reason" => [ + $navigation = new Navigation(); + foreach ($navigation->load($type) as $navItem) { + foreach (self::yieldNavigation($navItem) as $name => $url) { + $origins[] = [ + 'name' => $name, + 'url' => $url->getScheme() . '://' . $url->getHost(), + 'reason' => [ 'type' => 'navigation', - 'name' => $itemConfig->get('name'), - 'shared' => false, + 'name' => $name, + 'parent' => $navItem->getName(), + 'navType' => $type, ], ]; } } - $configShared = Config::navigation($type); - $configShared->getConfigObject()->setKeyColumn('name'); - foreach ($configShared->select() as $itemConfig) { - if (Icinga::app()->hasAccessToSharedNavigationItem($itemConfig, $config) && - $itemConfig->get("target", "") !== "_blank" - ) { - $menuItems[] = [ - "name" => $itemConfig->get('name'), - "url" => $itemConfig->get('url'), - "reason" => [ - 'type' => 'navigation', - 'name' => $itemConfig->get('name'), - 'shared' => true, - ], - ]; - } + } + + return $origins; + } + + protected static function yieldNavigation(NavigationItem $item): Generator + { + if ($item->hasChildren()) { + foreach ($item as $child) { + yield from self::yieldNavigation($child); + } + } else { + $url = $item->getUrl(); + if ($url === null) { + return; + } + if ($item->getTarget() !== '_blank' && $url->isExternal()) { + yield $item->getName() => $item->getUrl(); } } - return $menuItems; } /** * Fetches all dashlets for the current user that have an external URL. * * @return array A list of dashlets with their names and absolute URLs. - * // returns [['name' => 'Dashlet Name', 'url' => 'https://external.dashlet.com'], ...] + * // returns [ ['name' => 'Dashlet Name', 'url' => 'https://external.dashlet.com', 'reason' => [...] ], ...] */ protected static function fetchDashletsItems(): array { From 452ee7a6c231bb0416bd6b9e06b1b69024ef86de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Thu, 12 Mar 2026 16:10:06 +0100 Subject: [PATCH 21/83] fixup! hasAccessToSharedNavigationItem doesn't need to be public --- library/Icinga/Application/Web.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/library/Icinga/Application/Web.php b/library/Icinga/Application/Web.php index f3fe5e9bf4..5111c321c4 100644 --- a/library/Icinga/Application/Web.php +++ b/library/Icinga/Application/Web.php @@ -178,7 +178,7 @@ public function getViewRenderer() return $this->viewRenderer; } - public function hasAccessToSharedNavigationItem(&$config, Config $navConfig) + private function hasAccessToSharedNavigationItem(&$config, Config $navConfig) { // TODO: Provide a more sophisticated solution From bda449a6dc598f93a52fb3ec2f9dc282bd135ff0 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 22/83] Add info for navigation items --- library/Icinga/Web/Widget/CspConfigurationTable.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index fb0e243de6..2bb782f9f2 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -30,6 +30,9 @@ protected function assemble(): void $reason = $directiveGroup['reason']; $type = $reason['type']; $info = match ($type) { + 'navigation' => $reason['navType'] + . '/' . ($reason['parent'] !== null ? ($reason['parent'] . '/') : '') + . $reason['name'], 'dashlet' => $reason['pane'] . '/' . $reason['dashlet'], 'hook' => $reason['hook'], default => '-', From 56e34ed4e54a751e1aa2ceb224eaade6ca928f2e 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 23/83] 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 d2b865ca04..8c955f3c1f 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -85,6 +85,7 @@ protected function assemble(): void $customCspElement = $this->getElement('custom_csp'); if ($this->hasBeenSubmitted()) { if (! $useCustomCsp) { + Csp::createNonce(); $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); } return; @@ -99,6 +100,7 @@ protected function assemble(): void } } else { $this->getElement('hidden_custom_csp')->setValue($this->getValue('custom_csp')); + Csp::createNonce(); $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); } } From 9831e64077d9b46a8a8d27ef3fb51f3a3fcb7a07 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 24/83] 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 8c955f3c1f..802e68a918 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 25/83] 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 802e68a918..27bf2475e9 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -7,9 +7,11 @@ use Icinga\Application\Config; use Icinga\Util\Csp; use Icinga\Web\Session; +use ipl\Web\Common\CalloutType; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Common\FormUid; use ipl\Web\Compat\CompatForm; +use ipl\Web\Widget\Callout; class CspConfigForm extends CompatForm { @@ -50,8 +52,6 @@ protected function assemble(): void 'label' => $this->translate('Enable Custom CSP'), 'description' => $this->translate( 'Specify whether to use a custom, user provided, string as the CSP-Header.' - . ' If you decide to provide your own CSP-Header, you are entirely responsible for keeping it' - . ' up-to-date.' ), 'class' => 'autosubmit', ] @@ -61,6 +61,16 @@ protected function assemble(): void $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; if ($useCustomCsp) { + $this->addHtml((new Callout( + CalloutType::Warning, + $this->translate( + 'Be aware that the custom CSP-Header completely overrides the automatically generated one.' + . ' This means that you are solely responsible for keeping the custom CSP-Header up-to-date and secure.' + . ' If you do not know what you are doing, please leave this checkbox unchecked.' + ), + $this->translate('Warning: Use at your own risk!'), + ))->setFormElement()); + $this->addElement('textarea', 'custom_csp', [ 'label' => $this->translate('Custom CSP'), 'description' => $this->translate( From e5d9443410f0ce8c0f82d815bb2477894f75dd3e 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 26/83] 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 0e0444c0b8..43e144743f 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -67,40 +67,7 @@ public static function isCspEnabled(): bool public static function collectContentSecurityPolicyDirectives(): array { - $policyDirectives = []; - - // Whitelist the hosts in the custom NavigationItems configured for the user, - // so that the iframes can be rendered properly. - /** @var ConfigObject[] $navigationItems */ - $navigationItems = self::fetchDashletNavigationItemConfigs(); - foreach ($navigationItems as $navigationItem) { - $errorSource = sprintf("Navigation item %s", $navigationItem['name']); - - $host = parse_url($navigationItem["url"], PHP_URL_HOST); - // Make sure $url is actually valid; - if (filter_var($navigationItem["url"], FILTER_VALIDATE_URL) === false) { - Logger::debug("$errorSource: Skipping invalid url: $host"); - continue; - } - - $scheme = parse_url($navigationItem["url"], PHP_URL_SCHEME); - - if ($host === null) { - continue; - } - - $policy = $host; - if ($scheme !== null) { - $policy = "$scheme://$host"; - } - - $policyDirectives[] = [ - 'directives' => [ - 'frame-src' => [$policy], - ], - 'reason' => $navigationItem['reason'], - ]; - } + $policyDirectives = self::fetchDashletNavigationItemConfigs(); // Allow modules to add their own csp directives in a limited fashion. foreach (CspDirectiveHook::all() as $hook) { @@ -286,7 +253,6 @@ protected static function getInstance(): self * Fetches and merges configurations for navigation menu items and dashlets. * * @return array An array containing both navigation items and dashlet configurations. - * // returns [['name' => 'Item Name', 'url' => 'https://example.com'], ...] */ protected static function fetchDashletNavigationItemConfigs(): array { @@ -302,8 +268,7 @@ protected static function fetchDashletNavigationItemConfigs(): array * Iterates through all registered navigation types, loads both user-specific * and shared configurations, and returns a list of menu items. * - * @return array Each item is an associative array with 'name' and 'url' keys. - * Example: [ ['name' => 'Home', 'url' => '/', 'reason' => [...] ], ... ] + * @return array A list of CSP directives, one for each navigation-item that has an external URL. */ protected static function fetchNavigationItems(): array { @@ -319,14 +284,15 @@ protected static function fetchNavigationItems(): array foreach ($navigation->load($type) as $navItem) { foreach (self::yieldNavigation($navItem) as $name => $url) { $origins[] = [ - 'name' => $name, - 'url' => $url->getScheme() . '://' . $url->getHost(), + 'directives' => [ + 'frame-src' => [$url->getScheme() . '://' . $url->getHost()], + ], 'reason' => [ 'type' => 'navigation', 'name' => $name, 'parent' => $navItem->getName(), 'navType' => $type, - ], + ] ]; } } @@ -355,60 +321,49 @@ protected static function yieldNavigation(NavigationItem $item): Generator /** * Fetches all dashlets for the current user that have an external URL. * - * @return array A list of dashlets with their names and absolute URLs. - * // returns [ ['name' => 'Dashlet Name', 'url' => 'https://external.dashlet.com', 'reason' => [...] ], ...] + * @return array A list of CSP directives, one for each dashlet that has an external URL. */ protected static function fetchDashletsItems(): array { $user = Auth::getInstance()->getUser(); - $dashlets = []; + $origins = []; if ($user === null) { - return $dashlets; + return $origins; } $dashboard = new Dashboard(); $dashboard->setUser($user); $dashboard->load(); + /** @var Dashboard\Pane $pane */ foreach ($dashboard->getPanes() as $pane) { + /** @var Dashboard\Dashlet $dashlet */ foreach ($pane->getDashlets() as $dashlet) { $url = $dashlet->getUrl(); if ($url === null) { continue; } - $externalUrl = $url->getParam("url"); - if ($externalUrl !== null && filter_var($externalUrl, FILTER_VALIDATE_URL) !== false) { - $dashlets[] = [ - "name" => $dashlet->getName(), - "url" => $externalUrl, - "reason" => [ - "type" => "dashlet", - "user" => $user->getUsername(), - "pane" => $pane->getName(), - "dashlet" => $dashlet->getName(), - ], - ]; + $absoluteUrl = $url->isExternal() + ? $url->getAbsoluteUrl() + : $url->getParam('url'); + if ($absoluteUrl === null || filter_var($absoluteUrl, FILTER_VALIDATE_URL) === false) { continue; } - if ($url->isExternal()) { - $absoluteUrl = $url->getAbsoluteUrl(); - if (filter_var($absoluteUrl, FILTER_VALIDATE_URL) !== false) { - $dashlets[] = [ - "name" => $dashlet->getName(), - "url" => $absoluteUrl, - "reason" => [ - "type" => "dashlet-iframe", - "user" => $user->getUsername(), - "pane" => $pane->getName(), - "dashlet" => $dashlet->getName(), - ], - ]; - } - } + $origins[] = [ + 'directives' => [ + 'frame-src' => [$absoluteUrl], + ], + 'reason' => [ + 'type' => 'dashlet', + 'user' => $user->getUsername(), + 'pane' => $pane->getName(), + 'dashlet' => $dashlet->getName(), + ] + ]; } } - return $dashlets; + return $origins; } } From aa419a00ddbb02269ead6b8da2ecd96ebe1009d5 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 27/83] 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 43e144743f..a88d5a3e77 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -11,7 +11,6 @@ use Icinga\Application\Icinga; use Icinga\Application\Logger; use Icinga\Authentication\Auth; -use Icinga\Data\ConfigObject; use Icinga\Web\Navigation\Navigation; use Icinga\Web\Navigation\NavigationItem; use Icinga\Web\Response; @@ -67,51 +66,13 @@ public static function isCspEnabled(): bool public static function collectContentSecurityPolicyDirectives(): array { - $policyDirectives = self::fetchDashletNavigationItemConfigs(); - - // Allow modules to add their own csp directives in a limited fashion. - foreach (CspDirectiveHook::all() as $hook) { - $directives = []; - try { - foreach ($hook->getCspDirectives() as $directive => $policies) { - // policy names contain only lowercase letters and '-'. Reject anything else. - if (! preg_match('|^[a-z\-]+$|', $directive)) { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Invalid CSP directive found: $directive"); - continue; - } - - // The default-src can only ever be 'self'. Disallow any updates to it. - if ($directive === "default-src") { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Changing default-src is forbidden."); - continue; - } - - if (count($policies) === 0) { - continue; - } - - $directives[$directive] = $policies; - } - - if (count($directives) === 0) { - continue; - } - - $policyDirectives[] = [ - "directives" => $directives, - "reason" => [ - "type" => "hook", - "hook" => get_class($hook), - ], - ]; - } catch (Throwable $e) { - Logger::error('Failed to CSP hook on request: %s', $e); - } - } - - return $policyDirectives; + // Create an array here because system origins should always come first. + return array_merge( + iterator_to_array(self::yieldSystemOrigins()), + iterator_to_array(self::yieldNavigationOrigins()), + iterator_to_array(self::yieldDashletItems()), + iterator_to_array(self::yieldModuleOrigins()), + ); } /** @@ -156,20 +117,7 @@ protected static function getCustomContentSecurityPolicy(): ?string */ public static function getAutomaticContentSecurityPolicy(): string { - $csp = static::getInstance(); - - if (empty($csp->styleNonce)) { - throw new RuntimeException('No nonce set for CSS'); - } - - // These are the default directives that should always be enforced. 'self' is valid for all - // directives and will therefore not be listed here. - $cspDirectives = [ - 'style-src' => ["'nonce-{$csp->styleNonce}'"], - 'font-src' => ["data:"], - 'img-src' => ["data:"], - 'frame-src' => [], - ]; + $cspDirectives = []; $policyDirectives = self::collectContentSecurityPolicyDirectives(); @@ -186,7 +134,7 @@ public static function getAutomaticContentSecurityPolicy(): string foreach ($cspDirectives as $directive => $policyDirectives) { if (! empty($policyDirectives)) { $header .= ' ' . - implode(' ', array_merge([$directive, "'self'"], array_unique($policyDirectives))) + implode(' ', array_merge([$directive], array_unique($policyDirectives))) . ';'; } } @@ -249,17 +197,77 @@ protected static function getInstance(): self return static::$instance; } - /** - * Fetches and merges configurations for navigation menu items and dashlets. - * - * @return array An array containing both navigation items and dashlet configurations. - */ - protected static function fetchDashletNavigationItemConfigs(): array + protected static function yieldSystemOrigins(): Generator { - return array_merge( - self::fetchNavigationItems(), - self::fetchDashletsItems(), - ); + $csp = static::getInstance(); + + if (empty($csp->styleNonce)) { + throw new RuntimeException('No nonce set for CSS'); + } + + $items = [ + 'default-src' => ["'self'"], + 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], + 'font-src' => ["'self'", "data:"], + 'img-src' => ["'self'", "data:"], + 'frame-src' => ["'self'"], + ]; + + foreach ($items as $directive => $policies) { + yield [ + 'directives' => [ + $directive => $policies, + ], + 'reason' => [ + 'type' => 'system', + ] + ]; + } + } + + protected static function yieldModuleOrigins(): Generator + { + // Allow modules to add their own csp directives in a limited fashion. + foreach (CspDirectiveHook::all() as $hook) { + $directives = []; + try { + foreach ($hook->getCspDirectives() as $directive => $policies) { + // policy names contain only lowercase letters and '-'. Reject anything else. + if (! preg_match('|^[a-z\-]+$|', $directive)) { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Invalid CSP directive found: $directive"); + continue; + } + + // The default-src can only ever be 'self'. Disallow any updates to it. + if ($directive === "default-src") { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Changing default-src is forbidden."); + continue; + } + + if (count($policies) === 0) { + continue; + } + + $directives[$directive] = $policies; + } + + if (count($directives) === 0) { + continue; + } + + yield [ + "directives" => $directives, + "reason" => [ + "type" => "hook", + "hook" => get_class($hook), + ], + ]; + } catch (Throwable $e) { + Logger::error('Failed to CSP hook on request: %s', $e); + } + } } /** @@ -268,22 +276,21 @@ protected static function fetchDashletNavigationItemConfigs(): array * Iterates through all registered navigation types, loads both user-specific * and shared configurations, and returns a list of menu items. * - * @return array A list of CSP directives, one for each navigation-item that has an external URL. + * @return Generator A list of CSP directives, one for each navigation-item that has an external URL. */ - protected static function fetchNavigationItems(): array + protected static function yieldNavigationOrigins(): Generator { $auth = Auth::getInstance(); if (! $auth->isAuthenticated()) { - return []; + return; } - $origins = []; $navigationType = Navigation::getItemTypeConfiguration(); foreach ($navigationType as $type => $_) { $navigation = new Navigation(); foreach ($navigation->load($type) as $navItem) { foreach (self::yieldNavigation($navItem) as $name => $url) { - $origins[] = [ + yield [ 'directives' => [ 'frame-src' => [$url->getScheme() . '://' . $url->getHost()], ], @@ -297,8 +304,6 @@ protected static function fetchNavigationItems(): array } } } - - return $origins; } protected static function yieldNavigation(NavigationItem $item): Generator @@ -321,14 +326,13 @@ protected static function yieldNavigation(NavigationItem $item): Generator /** * Fetches all dashlets for the current user that have an external URL. * - * @return array A list of CSP directives, one for each dashlet that has an external URL. + * @return Generator A list of CSP directives, one for each dashlet that has an external URL. */ - protected static function fetchDashletsItems(): array + protected static function yieldDashletItems(): Generator { $user = Auth::getInstance()->getUser(); - $origins = []; if ($user === null) { - return $origins; + return; } $dashboard = new Dashboard(); @@ -351,7 +355,7 @@ protected static function fetchDashletsItems(): array continue; } - $origins[] = [ + yield [ 'directives' => [ 'frame-src' => [$absoluteUrl], ], @@ -364,6 +368,5 @@ protected static function fetchDashletsItems(): array ]; } } - return $origins; } } From d63ab85372bc89d66db1500958ee0a48c2e96cac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Fri, 13 Mar 2026 14:13:21 +0100 Subject: [PATCH 28/83] fixup! Code style --- 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 27bf2475e9..6f0e7342ca 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -65,8 +65,8 @@ protected function assemble(): void 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 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()); From 2b2c20f8d3770eb6ef546a838cf99649df819554 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 29/83] Write documentation & rename Items to Origins --- .../Application/Hook/CspDirectiveHook.php | 6 ++-- library/Icinga/Util/Csp.php | 35 ++++++++++++++++--- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspDirectiveHook.php index 93af328501..58351fc069 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspDirectiveHook.php @@ -10,8 +10,8 @@ abstract class CspDirectiveHook { /** * Allow the module to provide custom directives for the CSP header. The return value should be an array - * with directive as the key and the policies in an array as the value. The valid values can either be - * a concrete host, whitelisting subdomains for hosts or a custom nonce for that module. + * with a directive as the key and the policies in an array as the value. The valid values can either be + * a concrete host, allowlisting subdomains for hosts or custom nonce for that module. * * Example: [ 'img-src' => [ 'https://*.media.tumblr.com', 'https://http.cat/' ] ] * @@ -30,7 +30,7 @@ public static function all(): array } /** - * Register the class as a RequestHook implementation + * Register the class as a CspDirectiveHook implementation * * Call this method on your implementation during module initialization to make Icinga Web aware of your hook. */ diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index a88d5a3e77..1f43703678 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -64,13 +64,19 @@ public static function isCspEnabled(): bool return Config::app()->get('security', 'use_strict_csp', 'n') === 'y'; } + /** + * Collects all CSP directives in an array where the system defaults are first. + * This is done over using a Generator because the order of the directives is important. + * + * @return array the list of CSP directives + */ public static function collectContentSecurityPolicyDirectives(): array { // Create an array here because system origins should always come first. return array_merge( iterator_to_array(self::yieldSystemOrigins()), iterator_to_array(self::yieldNavigationOrigins()), - iterator_to_array(self::yieldDashletItems()), + iterator_to_array(self::yieldDashletOrigins()), iterator_to_array(self::yieldModuleOrigins()), ); } @@ -92,7 +98,13 @@ public static function getContentSecurityPolicy(): string return self::getAutomaticContentSecurityPolicy(); } - protected static function getCustomContentSecurityPolicy(): ?string + /** + * Get the custom Content-Security-Policy set in the config. + * This method automatically replaces new-lines and the {style_nonce} placeholder with the generated nonce. + * + * @return string Returns the custom CSP header value. + */ + protected static function getCustomContentSecurityPolicy(): string { $csp = static::getInstance(); @@ -113,7 +125,6 @@ protected static function getCustomContentSecurityPolicy(): ?string * * @return string Returns the generated header value. * @throws RuntimeException If no nonce set for CSS - * */ public static function getAutomaticContentSecurityPolicy(): string { @@ -197,6 +208,12 @@ protected static function getInstance(): self return static::$instance; } + /** + * Yields the system origins. + * These directives should always be added first. + * + * @return Generator + */ protected static function yieldSystemOrigins(): Generator { $csp = static::getInstance(); @@ -225,6 +242,10 @@ protected static function yieldSystemOrigins(): Generator } } + /** + * Yield all CSP directives from modules. See {@see CspDirectiveHook} for details. + * @return Generator + */ protected static function yieldModuleOrigins(): Generator { // Allow modules to add their own csp directives in a limited fashion. @@ -306,6 +327,12 @@ protected static function yieldNavigationOrigins(): Generator } } + /** + * Recursively yield all navigation items that have an external URL. + * + * @param NavigationItem $item The top-level navigation item to start from. + * @return Generator + */ protected static function yieldNavigation(NavigationItem $item): Generator { if ($item->hasChildren()) { @@ -328,7 +355,7 @@ protected static function yieldNavigation(NavigationItem $item): Generator * * @return Generator A list of CSP directives, one for each dashlet that has an external URL. */ - protected static function yieldDashletItems(): Generator + protected static function yieldDashletOrigins(): Generator { $user = Auth::getInstance()->getUser(); if ($user === null) { From e702437a29a5f69f902b9d17cb0197b298d4bede 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 30/83] 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 6f0e7342ca..f49cc9c1af 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -66,7 +66,7 @@ protected function assemble(): void $this->translate( 'Be aware that the custom CSP-Header completely overrides the automatically generated one.' . ' This means that you are solely responsible for keeping the custom CSP-Header up-to-date' - . ' and secure. If you do not know what you are doing, please leave this checkbox unchecked.' + . ' and secure.', ), $this->translate('Warning: Use at your own risk!'), ))->setFormElement()); From f48be7387483900917703871807699c27195f07c 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 31/83] 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 1f43703678..b7a96a321b 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -6,6 +6,7 @@ namespace Icinga\Util; use Generator; +use Icinga\Application\ClassLoader; use Icinga\Application\Config; use Icinga\Application\Hook\CspDirectiveHook; use Icinga\Application\Icinga; @@ -279,10 +280,10 @@ protected static function yieldModuleOrigins(): Generator } yield [ - "directives" => $directives, - "reason" => [ - "type" => "hook", - "hook" => get_class($hook), + 'directives' => $directives, + 'reason' => [ + 'type' => 'module', + 'module' => ClassLoader::extractModuleName(get_class($hook)), ], ]; } catch (Throwable $e) { diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index c23fe3bbed..ad5b4b8827 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -36,7 +36,7 @@ protected function assemble(): void . '/' . ($reason['parent'] !== null ? ($reason['parent'] . '/') : '') . $reason['name'], 'dashlet' => $reason['pane'] . '/' . $reason['dashlet'], - 'hook' => $reason['hook'], + 'module' => $reason['module'], default => '-', }; foreach ($directiveGroup['directives'] as $directive => $policies) { From 857811894873d24e5b1fe6387c7997b5e3ca1721 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 32/83] Apply code review changes --- .../Config/General/ApplicationConfigForm.php | 2 -- .../Web/Widget/CspConfigurationTable.php | 20 +++++++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/application/forms/Config/General/ApplicationConfigForm.php b/application/forms/Config/General/ApplicationConfigForm.php index 47a21c8da3..d33b865822 100644 --- a/application/forms/Config/General/ApplicationConfigForm.php +++ b/application/forms/Config/General/ApplicationConfigForm.php @@ -6,10 +6,8 @@ namespace Icinga\Forms\Config\General; use Icinga\Application\Icinga; -use Icinga\Authentication\Auth; use Icinga\Data\ResourceFactory; use Icinga\Web\Form; -use Icinga\Util\Csp; /** * Configuration form for general application options diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index ad5b4b8827..1696096726 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -19,11 +19,11 @@ public function __construct() protected function assemble(): void { - $this->add(self::tr([ - self::th($this->translate('Type')), - self::th($this->translate('Info')), - self::th($this->translate('Directive')), - self::th($this->translate('Value')), + $this->add(static::tr([ + static::th($this->translate('Type')), + static::th($this->translate('Info')), + static::th($this->translate('Directive')), + static::th($this->translate('Value')), ])); $policyDirectives = Csp::collectContentSecurityPolicyDirectives(); @@ -40,11 +40,11 @@ protected function assemble(): void default => '-', }; foreach ($directiveGroup['directives'] as $directive => $policies) { - $this->add(self::tr([ - self::td($type), - self::td($info), - self::td($directive), - self::td(join(', ', $policies)), + $this->add(static::tr([ + static::td($type), + static::td($info), + static::td($directive), + static::td(join(', ', $policies)), ])); } } From 3b41810a49360d69724ab038194aa75dced1bbf7 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 33/83] Hide unused form elements and table if CSP is disabled --- application/controllers/ConfigController.php | 7 +- .../forms/Config/General/CspConfigForm.php | 111 ++++++++++-------- 2 files changed, 67 insertions(+), 51 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 145ccdd82e..0aeaff40b1 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -9,6 +9,7 @@ use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; use Icinga\Forms\Config\General\CspConfigForm; +use Icinga\Util\Csp; use Icinga\Web\Widget\CspConfigurationTable; use InvalidArgumentException; use Icinga\Application\Config; @@ -117,7 +118,11 @@ public function generalAction() $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; - $this->view->cspTable = (new CspConfigurationTable())->render(); + if ($cspForm->getPopulatedValue('use_strict_csp', 'n') === 'y') { + $this->view->cspTable = (new CspConfigurationTable())->render(); + } else { + $this->view->cspTable = ''; + } $this->createApplicationTabs()->activate('general'); } diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index f49cc9c1af..f3f45a88bd 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -37,63 +37,71 @@ protected function assemble(): void 'checkbox', 'use_strict_csp', [ - 'label' => $this->translate('Enable strict CSP'), - 'description' => $this->translate( + 'label' => $this->translate('Enable strict CSP'), + 'description' => $this->translate( 'Set whether to use strict content security policy (CSP).' - . ' This setting helps to protect from cross-site scripting (XSS).' - ), - ], - ); - - $this->addElement( - 'checkbox', - 'use_custom_csp', - [ - 'label' => $this->translate('Enable Custom CSP'), - 'description' => $this->translate( - 'Specify whether to use a custom, user provided, string as the CSP-Header.' + . ' This setting helps to protect from cross-site scripting (XSS).', ), 'class' => 'autosubmit', - ] + ], ); - $this->addElement('hidden', 'hidden_custom_csp'); - - $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; - if ($useCustomCsp) { - $this->addHtml((new Callout( - CalloutType::Warning, - $this->translate( - 'Be aware that the custom CSP-Header completely overrides the automatically generated one.' - . ' This means that you are solely responsible for keeping the custom CSP-Header up-to-date' - . ' and secure.', - ), - $this->translate('Warning: Use at your own risk!'), - ))->setFormElement()); - - $this->addElement('textarea', 'custom_csp', [ - 'label' => $this->translate('Custom CSP'), - 'description' => $this->translate( - 'Set a custom CSP-Header. This completely overrides the automatically generated one.' - . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.' - ), - 'disabled' => false, - ]); - } else { - $this->addElement('textarea', 'custom_csp', [ - 'label' => $this->translate('Generated CSP'), - 'description' => $this->translate( - 'This is the current CSP-Header. You can always safely go back to this by disabling the' - . ' Enable Custom CSP checkbox above.' - ), - 'disabled' => true, - ]); + $useCsp = $this->getPopulatedValue('use_strict_csp', 'n') === 'y'; + if ($useCsp) { + $this->addElement( + 'checkbox', + 'use_custom_csp', + [ + 'label' => $this->translate('Enable Custom CSP'), + 'description' => $this->translate( + 'Specify whether to use a custom, user provided, string as the CSP-Header.', + ), + 'class' => 'autosubmit', + ], + ); + + $this->addElement('hidden', 'hidden_custom_csp'); + + $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; + if ($useCustomCsp) { + $this->addHtml((new Callout( + CalloutType::Warning, + $this->translate( + 'Be aware that the custom CSP-Header completely overrides the automatically generated one.' + . ' This means that you are solely responsible for keeping the custom CSP-Header up-to-date' + . ' and secure.', + ), + $this->translate('Warning: Use at your own risk!'), + ))->setFormElement()); + + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate('Custom CSP'), + 'description' => $this->translate( + 'Set a custom CSP-Header. This completely overrides the automatically generated one.' + . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.', + ), + 'disabled' => false, + ]); + } else { + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate('Generated CSP'), + 'description' => $this->translate( + 'This is the current CSP-Header. You can always safely go back to this by disabling the' + . ' Enable Custom CSP checkbox above.', + ), + 'disabled' => true, + ]); + } } $this->addElement('submit', 'submit', [ 'label' => t('Save changes'), ]); + if (! $useCsp) { + return; + } + $customCspElement = $this->getElement('custom_csp'); if ($this->hasBeenSubmitted()) { if (! $useCustomCsp) { @@ -123,10 +131,13 @@ protected function onSuccess(): void $section = $config->getSection('security'); $section['use_strict_csp'] = $this->getValue('use_strict_csp'); - $section['use_custom_csp'] = $this->getValue('use_custom_csp'); - $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; - if ($useCustomCsp) { - $section['custom_csp'] = $this->getValue('custom_csp'); + $useCsp = $this->getPopulatedValue('use_strict_csp', 'n') === 'y'; + if ($useCsp) { + $section['use_custom_csp'] = $this->getValue('use_custom_csp'); + $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; + if ($useCustomCsp) { + $section['custom_csp'] = $this->getValue('custom_csp'); + } } $config->setSection('security', $section); From 40a5a44f664f71ee84258cab704946abef9e66e2 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 34/83] Automatically reload the window on form success if CSP is active --- application/controllers/ConfigController.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 0aeaff40b1..42aa195dcc 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -9,7 +9,6 @@ use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; use Icinga\Forms\Config\General\CspConfigForm; -use Icinga\Util\Csp; use Icinga\Web\Widget\CspConfigurationTable; use InvalidArgumentException; use Icinga\Application\Config; @@ -29,6 +28,7 @@ use Icinga\Web\Notification; use Icinga\Web\Url; use Icinga\Web\Widget; +use ipl\Html\Form; /** * Application and module configuration @@ -115,6 +115,13 @@ public function generalAction() 'use_strict_csp' => $config->get('security', 'use_strict_csp'), 'use_custom_csp' => $config->get('security', 'use_custom_csp'), ]); + + $cspForm->on(Form::ON_SUBMIT, function (Form $form) use ($config) { + $useCsp = $form->getPopulatedValue('use_strict_csp', 'n') === 'y'; + if ($useCsp) { + $this->getResponse()->setReloadWindow(true); + } + }); $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; From 9a781585b812ac9df2e6feaac1e4acbe8a32f864 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 35/83] Change URLs in method documentation CspDirectiveHook::getCspDirectives() --- library/Icinga/Application/Hook/CspDirectiveHook.php | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspDirectiveHook.php index 58351fc069..e592cdc86b 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspDirectiveHook.php @@ -6,6 +6,10 @@ use Icinga\Application\Hook; +/** + * Allow modules to provide custom CSP directives. + * This hook is only used if the CSP header is enabled. + */ abstract class CspDirectiveHook { /** @@ -13,7 +17,7 @@ abstract class CspDirectiveHook * with a directive as the key and the policies in an array as the value. The valid values can either be * a concrete host, allowlisting subdomains for hosts or custom nonce for that module. * - * Example: [ 'img-src' => [ 'https://*.media.tumblr.com', 'https://http.cat/' ] ] + * Example: [ 'img-src' => [ 'https://*.icinga.com', 'https://example.com/' ] ] * * @return array The CSP directives are the keys and the policies the values. */ From 4d41fd251f50754aaaaa7d653498c391dcf3368f 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 36/83] Use getValue instead of getPopulatedValue --- application/controllers/ConfigController.php | 7 ++++--- application/forms/Config/General/CspConfigForm.php | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 42aa195dcc..c38887147d 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -9,6 +9,7 @@ use GuzzleHttp\Psr7\ServerRequest; use Icinga\Application\Version; use Icinga\Forms\Config\General\CspConfigForm; +use Icinga\Util\Csp; use Icinga\Web\Widget\CspConfigurationTable; use InvalidArgumentException; use Icinga\Application\Config; @@ -112,12 +113,12 @@ public function generalAction() $config = Config::app(); $cspForm = new CspConfigForm($config); $cspForm->populate([ - 'use_strict_csp' => $config->get('security', 'use_strict_csp'), + 'use_strict_csp' => Csp::isCspEnabled(), 'use_custom_csp' => $config->get('security', 'use_custom_csp'), ]); $cspForm->on(Form::ON_SUBMIT, function (Form $form) use ($config) { - $useCsp = $form->getPopulatedValue('use_strict_csp', 'n') === 'y'; + $useCsp = $form->getValue('use_strict_csp') === 'y'; if ($useCsp) { $this->getResponse()->setReloadWindow(true); } @@ -125,7 +126,7 @@ public function generalAction() $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; - if ($cspForm->getPopulatedValue('use_strict_csp', 'n') === 'y') { + if ($cspForm->getValue('use_strict_csp') === 'y') { $this->view->cspTable = (new CspConfigurationTable())->render(); } else { $this->view->cspTable = ''; diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index f3f45a88bd..462ed8d643 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -46,7 +46,7 @@ protected function assemble(): void ], ); - $useCsp = $this->getPopulatedValue('use_strict_csp', 'n') === 'y'; + $useCsp = $this->getValue('use_strict_csp') === 'y'; if ($useCsp) { $this->addElement( 'checkbox', @@ -62,7 +62,7 @@ protected function assemble(): void $this->addElement('hidden', 'hidden_custom_csp'); - $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; + $useCustomCsp = $this->getValue('use_custom_csp') === 'y'; if ($useCustomCsp) { $this->addHtml((new Callout( CalloutType::Warning, @@ -112,7 +112,7 @@ protected function assemble(): void } if ($useCustomCsp) { - $value = $this->getPopulatedValue('hidden_custom_csp'); + $value = $this->getValue('hidden_custom_csp'); if (! empty($value)) { $customCspElement->setValue($value); } else { From 2856c7e5cd3a691c762687a8df23918363ed87df 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 37/83] 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 b7a96a321b..7731249e55 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -62,7 +62,9 @@ public static function addHeader(Response $response): void public static function isCspEnabled(): bool { - return Config::app()->get('security', 'use_strict_csp', 'n') === 'y'; + $value = Config::app()->get('security', 'use_strict_csp', 'n'); + + return in_array($value, ['y', '1']); } /** @@ -118,6 +120,7 @@ protected static function getCustomContentSecurityPolicy(): string $customCsp = str_replace("\r\n", ' ', $customCsp); $customCsp = str_replace("\n", ' ', $customCsp); $customCsp = str_replace('{style_nonce}', "'nonce-{$csp->styleNonce}'", $customCsp); + return $customCsp; } From 152ebb383cb717b9a86cd63c958ab40abaf97a82 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 38/83] Use a hidden element with the same name to store the custom value --- application/controllers/ConfigController.php | 1 + .../forms/Config/General/CspConfigForm.php | 41 ++++--------------- 2 files changed, 8 insertions(+), 34 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index c38887147d..3c07ec2a4f 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -115,6 +115,7 @@ public function generalAction() $cspForm->populate([ 'use_strict_csp' => Csp::isCspEnabled(), 'use_custom_csp' => $config->get('security', 'use_custom_csp'), + 'custom_csp' => $config->get('security', 'custom_csp'), ]); $cspForm->on(Form::ON_SUBMIT, function (Form $form) use ($config) { diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 462ed8d643..94b6317bf8 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -46,8 +46,7 @@ protected function assemble(): void ], ); - $useCsp = $this->getValue('use_strict_csp') === 'y'; - if ($useCsp) { + if ($this->getValue('use_strict_csp') === 'y') { $this->addElement( 'checkbox', 'use_custom_csp', @@ -60,10 +59,7 @@ protected function assemble(): void ], ); - $this->addElement('hidden', 'hidden_custom_csp'); - - $useCustomCsp = $this->getValue('use_custom_csp') === 'y'; - if ($useCustomCsp) { + if ($this->getValue('use_custom_csp') === 'y') { $this->addHtml((new Callout( CalloutType::Warning, $this->translate( @@ -80,16 +76,19 @@ protected function assemble(): void 'Set a custom CSP-Header. This completely overrides the automatically generated one.' . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.', ), - 'disabled' => false, ]); } else { - $this->addElement('textarea', 'custom_csp', [ + $this->addElement('hidden', 'custom_csp'); + + Csp::createNonce(); + $this->addElement('textarea', 'generated_csp', [ 'label' => $this->translate('Generated CSP'), 'description' => $this->translate( 'This is the current CSP-Header. You can always safely go back to this by disabling the' . ' Enable Custom CSP checkbox above.', ), 'disabled' => true, + 'value' => Csp::getAutomaticContentSecurityPolicy(), ]); } } @@ -97,32 +96,6 @@ protected function assemble(): void $this->addElement('submit', 'submit', [ 'label' => t('Save changes'), ]); - - if (! $useCsp) { - return; - } - - $customCspElement = $this->getElement('custom_csp'); - if ($this->hasBeenSubmitted()) { - if (! $useCustomCsp) { - Csp::createNonce(); - $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); - } - return; - } - - if ($useCustomCsp) { - $value = $this->getValue('hidden_custom_csp'); - if (! empty($value)) { - $customCspElement->setValue($value); - } else { - $customCspElement->setValue($this->config->get('security', 'custom_csp')); - } - } else { - $this->getElement('hidden_custom_csp')->setValue($this->getValue('custom_csp')); - Csp::createNonce(); - $customCspElement->setValue(Csp::getAutomaticContentSecurityPolicy()); - } } protected function onSuccess(): void From 8c2e86dc77d7f2841fcb9d0d6fd892a1c58aa7fd 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 39/83] Remove superfluous mentions of CSP inside the Csp class --- application/controllers/ConfigController.php | 2 +- .../forms/Config/General/CspConfigForm.php | 2 +- library/Icinga/Util/Csp.php | 23 +++++++++---------- library/Icinga/Web/Response.php | 2 +- .../Web/Widget/CspConfigurationTable.php | 2 +- 5 files changed, 15 insertions(+), 16 deletions(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 3c07ec2a4f..6107b1eade 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -113,7 +113,7 @@ public function generalAction() $config = Config::app(); $cspForm = new CspConfigForm($config); $cspForm->populate([ - 'use_strict_csp' => Csp::isCspEnabled(), + 'use_strict_csp' => Csp::isEnabled(), 'use_custom_csp' => $config->get('security', 'use_custom_csp'), 'custom_csp' => $config->get('security', 'custom_csp'), ]); diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 94b6317bf8..0e31eac639 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -88,7 +88,7 @@ protected function assemble(): void . ' Enable Custom CSP checkbox above.', ), 'disabled' => true, - 'value' => Csp::getAutomaticContentSecurityPolicy(), + 'value' => Csp::getAutomaticHeaderValue(), ]); } } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 7731249e55..671d434afe 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -56,11 +56,11 @@ private function __construct() */ public static function addHeader(Response $response): void { - $header = static::getContentSecurityPolicy(); + $header = static::getHeader(); $response->setHeader('Content-Security-Policy', $header, true); } - public static function isCspEnabled(): bool + public static function isEnabled(): bool { $value = Config::app()->get('security', 'use_strict_csp', 'n'); @@ -73,7 +73,7 @@ public static function isCspEnabled(): bool * * @return array the list of CSP directives */ - public static function collectContentSecurityPolicyDirectives(): array + public static function collectDirectives(): array { // Create an array here because system origins should always come first. return array_merge( @@ -85,20 +85,19 @@ public static function collectContentSecurityPolicyDirectives(): array } /** - * Get the Content-Security-Policy. + * Get the Content-Security-Policy header. * - * @return string Returns the generated header value. + * @return string Returns the CSP header for this request. * @throws RuntimeException If no nonce set for CSS - * */ - public static function getContentSecurityPolicy(): string + public static function getHeader(): string { $config = Config::app(); if ($config->get('security', 'use_custom_csp', 'y') === 'y') { - return self::getCustomContentSecurityPolicy(); + return self::getCustomHeaderValue(); } - return self::getAutomaticContentSecurityPolicy(); + return self::getAutomaticHeaderValue(); } /** @@ -107,7 +106,7 @@ public static function getContentSecurityPolicy(): string * * @return string Returns the custom CSP header value. */ - protected static function getCustomContentSecurityPolicy(): string + protected static function getCustomHeaderValue(): string { $csp = static::getInstance(); @@ -130,11 +129,11 @@ protected static function getCustomContentSecurityPolicy(): string * @return string Returns the generated header value. * @throws RuntimeException If no nonce set for CSS */ - public static function getAutomaticContentSecurityPolicy(): string + public static function getAutomaticHeaderValue(): string { $cspDirectives = []; - $policyDirectives = self::collectContentSecurityPolicyDirectives(); + $policyDirectives = self::collectDirectives(); foreach ($policyDirectives as $directive) { foreach ($directive['directives'] as $directive => $policies) { diff --git a/library/Icinga/Web/Response.php b/library/Icinga/Web/Response.php index 7a9e6033b7..6cb25ae163 100644 --- a/library/Icinga/Web/Response.php +++ b/library/Icinga/Web/Response.php @@ -383,7 +383,7 @@ protected function prepare() $this->setRedirect($redirectUrl->getAbsoluteUrl()); } - if (Csp::getStyleNonce() && Csp::isCspEnabled()) { + if (Csp::getStyleNonce() && Csp::isEnabled()) { Csp::addHeader($this); } } diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index 1696096726..d7a14adcfc 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -26,7 +26,7 @@ protected function assemble(): void static::th($this->translate('Value')), ])); - $policyDirectives = Csp::collectContentSecurityPolicyDirectives(); + $policyDirectives = Csp::collectDirectives(); foreach ($policyDirectives as $directiveGroup) { $reason = $directiveGroup['reason']; From 9663669f4bf35cc8c9f8ad8baedb10351bf9c698 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Mon, 16 Mar 2026 12:49:00 +0100 Subject: [PATCH 40/83] Use constructor promotion --- application/forms/Config/General/CspConfigForm.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 0e31eac639..f984987944 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -18,11 +18,8 @@ class CspConfigForm extends CompatForm use FormUid; use CsrfCounterMeasure; - protected Config $config; - - public function __construct(Config $config) + public function __construct(protected Config $config) { - $this->config = $config; $this->setAttribute("name", "csp_config"); $this->applyDefaultElementDecorators(); } From a00a051baa2bf2f6868d82b7e1419ee9eb1cb281 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 41/83] 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 671d434afe..c3ba1c9ef2 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -144,16 +144,16 @@ public static function getAutomaticHeaderValue(): string } } - $header = "default-src 'self'; "; + $headerSegments = []; foreach ($cspDirectives as $directive => $policyDirectives) { - if (! empty($policyDirectives)) { - $header .= ' ' . - implode(' ', array_merge([$directive], array_unique($policyDirectives))) - . ';'; + if (empty($policyDirectives)) { + continue; } + + $headerSegments[] = implode(' ', array_merge([$directive], array_unique($policyDirectives))); } - return $header; + return implode('; ', $headerSegments); } /** From 9dc386fe98e4aac1b3b7984af5d98a52b1eb444e 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 42/83] Store populated values in hidden form elements --- application/forms/Config/General/CspConfigForm.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index f984987944..2ba477d952 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -43,7 +43,10 @@ protected function assemble(): void ], ); - if ($this->getValue('use_strict_csp') === 'y') { + if ($this->getValue('use_strict_csp') !== 'y') { + $this->addElement('hidden', 'use_custom_csp'); + $this->addElement('hidden', 'custom_csp'); + } else { $this->addElement( 'checkbox', 'use_custom_csp', From 8f2f8306acba25769fa80d9d4d5647bca751a9a2 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 43/83] 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 6107b1eade..ed363f95db 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -29,6 +29,7 @@ use Icinga\Web\Notification; use Icinga\Web\Url; use Icinga\Web\Widget; +use ipl\Html\Contract\Form as ContractForm; use ipl\Html\Form; /** @@ -118,16 +119,15 @@ public function generalAction() 'custom_csp' => $config->get('security', 'custom_csp'), ]); - $cspForm->on(Form::ON_SUBMIT, function (Form $form) use ($config) { - $useCsp = $form->getValue('use_strict_csp') === 'y'; - if ($useCsp) { + $cspForm->on(ContractForm::ON_SUBMIT, function (CspConfigForm $form) use ($config) { + if ($form->isCspEnabled() && $form->hasConfigChanged()) { $this->getResponse()->setReloadWindow(true); } }); $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; - if ($cspForm->getValue('use_strict_csp') === 'y') { + if ($cspForm->isCspEnabled()) { $this->view->cspTable = (new CspConfigurationTable())->render(); } else { $this->view->cspTable = ''; diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 2ba477d952..524f2da4f7 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -18,6 +18,8 @@ class CspConfigForm extends CompatForm use FormUid; use CsrfCounterMeasure; + protected bool $changed = false; + public function __construct(protected Config $config) { $this->setAttribute("name", "csp_config"); @@ -43,7 +45,7 @@ protected function assemble(): void ], ); - if ($this->getValue('use_strict_csp') !== 'y') { + if (! $this->isCspEnabled()) { $this->addElement('hidden', 'use_custom_csp'); $this->addElement('hidden', 'custom_csp'); } else { @@ -59,7 +61,7 @@ protected function assemble(): void ], ); - if ($this->getValue('use_custom_csp') === 'y') { + if ($this->isCustomCspEnabled()) { $this->addHtml((new Callout( CalloutType::Warning, $this->translate( @@ -103,17 +105,39 @@ protected function onSuccess(): void $config = Config::app(); $section = $config->getSection('security'); + $beforeSection = clone $section; $section['use_strict_csp'] = $this->getValue('use_strict_csp'); - $useCsp = $this->getPopulatedValue('use_strict_csp', 'n') === 'y'; - if ($useCsp) { + if ($this->isCspEnabled()) { $section['use_custom_csp'] = $this->getValue('use_custom_csp'); - $useCustomCsp = $this->getPopulatedValue('use_custom_csp', 'n') === 'y'; - if ($useCustomCsp) { + if ($this->isCustomCspEnabled()) { $section['custom_csp'] = $this->getValue('custom_csp'); } } - $config->setSection('security', $section); + + $this->changed = ! empty(array_diff_assoc( + iterator_to_array($section), + iterator_to_array($beforeSection) + )); + + if (! $this->changed) { + return; + } $config->saveIni(); } + + public function hasConfigChanged(): bool + { + return $this->changed; + } + + public function isCspEnabled(): bool + { + return $this->getValue('use_strict_csp') === 'y'; + } + + public function isCustomCspEnabled(): bool + { + return $this->getValue('use_custom_csp') === 'y'; + } } From ca0f3e95751825e71a49f4e2307765842168fec1 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 44/83] 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 c3ba1c9ef2..3086f437d5 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -342,14 +342,14 @@ protected static function yieldNavigation(NavigationItem $item): Generator foreach ($item as $child) { yield from self::yieldNavigation($child); } - } else { - $url = $item->getUrl(); - if ($url === null) { - return; - } - if ($item->getTarget() !== '_blank' && $url->isExternal()) { - yield $item->getName() => $item->getUrl(); - } + } + + $url = $item->getUrl(); + if ($url === null) { + return; + } + if ($item->getTarget() !== '_blank' && $url->isExternal()) { + yield $item->getName() => $item->getUrl(); } } From f39dd90f872812a3cadf1d886e89165e50f90bb0 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 45/83] 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 3086f437d5..4185219496 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -15,6 +15,7 @@ use Icinga\Web\Navigation\Navigation; use Icinga\Web\Navigation\NavigationItem; use Icinga\Web\Response; +use Icinga\Web\Url; use Icinga\Web\Widget\Dashboard; use Icinga\Web\Window; use RuntimeException; @@ -314,9 +315,13 @@ protected static function yieldNavigationOrigins(): Generator $navigation = new Navigation(); foreach ($navigation->load($type) as $navItem) { foreach (self::yieldNavigation($navItem) as $name => $url) { + $cspUrl = $url->getScheme() . '://' . $url->getHost(); + if (($port = $url->getPort()) !== null) { + $cspUrl .= ':' . $port; + } yield [ 'directives' => [ - 'frame-src' => [$url->getScheme() . '://' . $url->getHost()], + 'frame-src' => [$cspUrl], ], 'reason' => [ 'type' => 'navigation', @@ -385,9 +390,16 @@ protected static function yieldDashletOrigins(): Generator continue; } + $absoluteUrl = Url::fromPath($absoluteUrl); + + $cspUrl = $absoluteUrl->getScheme() . '://' . $absoluteUrl->getHost(); + if (($port = $absoluteUrl->getPort()) !== null) { + $cspUrl .= ':' . $port; + } + yield [ 'directives' => [ - 'frame-src' => [$absoluteUrl], + 'frame-src' => [$cspUrl], ], 'reason' => [ 'type' => 'dashlet', From 1b861c95dca17d60d69070a05f24af41669e2bdf 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 46/83] 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 4185219496..a34fad40bf 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -326,7 +326,7 @@ protected static function yieldNavigationOrigins(): Generator 'reason' => [ 'type' => 'navigation', 'name' => $name, - 'parent' => $navItem->getName(), + 'parent' => $name !== $navItem->getName() ? $navItem->getName() : null, 'navType' => $type, ] ]; From 7989c69ec6ac162542cb154a40be38a0c0220034 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 47/83] Use 0/1 instead of n/y for config values This makes it compatible with previous versions. --- .../forms/Config/General/CspConfigForm.php | 20 +++++++++++-------- library/Icinga/Util/Csp.php | 6 ++---- 2 files changed, 14 insertions(+), 12 deletions(-) diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 524f2da4f7..6402f7dff7 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -36,12 +36,14 @@ protected function assemble(): void 'checkbox', 'use_strict_csp', [ - 'label' => $this->translate('Enable strict CSP'), - 'description' => $this->translate( + 'label' => $this->translate('Enable strict CSP'), + 'description' => $this->translate( 'Set whether to use strict content security policy (CSP).' . ' This setting helps to protect from cross-site scripting (XSS).', ), - 'class' => 'autosubmit', + 'class' => 'autosubmit', + 'checkedValue' => '1', + 'uncheckedValue' => '0', ], ); @@ -53,11 +55,13 @@ protected function assemble(): void 'checkbox', 'use_custom_csp', [ - 'label' => $this->translate('Enable Custom CSP'), - 'description' => $this->translate( + 'label' => $this->translate('Enable Custom CSP'), + 'description' => $this->translate( 'Specify whether to use a custom, user provided, string as the CSP-Header.', ), - 'class' => 'autosubmit', + 'class' => 'autosubmit', + 'checkedValue' => '1', + 'uncheckedValue' => '0', ], ); @@ -133,11 +137,11 @@ public function hasConfigChanged(): bool public function isCspEnabled(): bool { - return $this->getValue('use_strict_csp') === 'y'; + return $this->getValue('use_strict_csp') === '1'; } public function isCustomCspEnabled(): bool { - return $this->getValue('use_custom_csp') === 'y'; + return $this->getValue('use_custom_csp') === '1'; } } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index a34fad40bf..7c1b3dd74e 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -63,9 +63,7 @@ public static function addHeader(Response $response): void public static function isEnabled(): bool { - $value = Config::app()->get('security', 'use_strict_csp', 'n'); - - return in_array($value, ['y', '1']); + return Config::app()->get('security', 'use_strict_csp', '0') === '1'; } /** @@ -94,7 +92,7 @@ public static function collectDirectives(): array public static function getHeader(): string { $config = Config::app(); - if ($config->get('security', 'use_custom_csp', 'y') === 'y') { + if ($config->get('security', 'use_custom_csp', '0') === '1') { return self::getCustomHeaderValue(); } From 2e05f497a70ddfa2048318a451d9dbf8dd474b23 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 48/83] 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 7c1b3dd74e..c5913b5534 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -401,7 +401,6 @@ protected static function yieldDashletOrigins(): Generator ], 'reason' => [ 'type' => 'dashlet', - 'user' => $user->getUsername(), 'pane' => $pane->getName(), 'dashlet' => $dashlet->getName(), ] From 864f801be508d776b945b756df2149d3bb3dfd14 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 49/83] 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 c5913b5534..b9e2b7e58b 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -68,19 +68,15 @@ public static function isEnabled(): bool /** * Collects all CSP directives in an array where the system defaults are first. - * This is done over using a Generator because the order of the directives is important. * - * @return array the list of CSP directives + * @return Generator the list of CSP directives */ - public static function collectDirectives(): array + public static function collectDirectives(): Generator { - // Create an array here because system origins should always come first. - return array_merge( - iterator_to_array(self::yieldSystemOrigins()), - iterator_to_array(self::yieldNavigationOrigins()), - iterator_to_array(self::yieldDashletOrigins()), - iterator_to_array(self::yieldModuleOrigins()), - ); + yield from self::yieldSystemOrigins(); + yield from self::yieldNavigationOrigins(); + yield from self::yieldDashletOrigins(); + yield from self::yieldModuleOrigins(); } /** @@ -131,10 +127,7 @@ protected static function getCustomHeaderValue(): string public static function getAutomaticHeaderValue(): string { $cspDirectives = []; - - $policyDirectives = self::collectDirectives(); - - foreach ($policyDirectives as $directive) { + foreach (self::collectDirectives() as $directive) { foreach ($directive['directives'] as $directive => $policies) { if (! isset($cspDirectives[$directive])) { $cspDirectives[$directive] = []; @@ -143,6 +136,8 @@ public static function getAutomaticHeaderValue(): string } } + unset($policyDirectives); + $headerSegments = []; foreach ($cspDirectives as $directive => $policyDirectives) { if (empty($policyDirectives)) { diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index d7a14adcfc..a32441729e 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -26,10 +26,8 @@ protected function assemble(): void static::th($this->translate('Value')), ])); - $policyDirectives = Csp::collectDirectives(); - - foreach ($policyDirectives as $directiveGroup) { - $reason = $directiveGroup['reason']; + foreach (Csp::collectDirectives() as $directive) { + $reason = $directive['reason']; $type = $reason['type']; $info = match ($type) { 'navigation' => $reason['navType'] @@ -39,7 +37,7 @@ protected function assemble(): void 'module' => $reason['module'], default => '-', }; - foreach ($directiveGroup['directives'] as $directive => $policies) { + foreach ($directive['directives'] as $directive => $policies) { $this->add(static::tr([ static::td($type), static::td($info), From 1865150152d84c461ddba6129be2b73f12069128 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 50/83] Split CSP-Table into multiple with apropriate headers. This commit also adds parsing to the policies. Turning urls into clickable links, and coloring potentially dangerous policies orange. --- .../Web/Widget/CspConfigurationTable.php | 222 ++++++++++++++++-- 1 file changed, 196 insertions(+), 26 deletions(-) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index a32441729e..46e05b2792 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -5,46 +5,216 @@ namespace Icinga\Web\Widget; use Icinga\Util\Csp; +use ipl\Html\BaseHtmlElement; +use ipl\Html\HtmlElement; use ipl\Html\Table; use ipl\I18n\Translation; -class CspConfigurationTable extends Table +class CspConfigurationTable extends BaseHtmlElement { use Translation; + protected $tag = 'div'; + public function __construct() { $this->getAttributes()->add('class', 'csp-config-table'); } + protected function buildTable( + string $filterType, + array $csp, + array $header, + callable $rowBuilder + ): Table { + $table = new Table(); + $headerRow = Table::tr(); + foreach ($header as $h) { + $headerRow->add(Table::th($h)); + } + $table->add($headerRow); + foreach ($csp as $row) { + $reason = $row['reason']; + $type = $reason['type']; + if ($type !== $filterType) { + continue; + } + foreach ($row['directives'] as $directive => $policies) { + if (count($policies) === 0) { + continue; + } + foreach ($policies as $k => $policy) { + $table->add($rowBuilder($reason, $directive, $policy)); + } + } + } + return $table; + } + protected function assemble(): void { - $this->add(static::tr([ - static::th($this->translate('Type')), - static::th($this->translate('Info')), - static::th($this->translate('Directive')), - static::th($this->translate('Value')), - ])); - - foreach (Csp::collectDirectives() as $directive) { - $reason = $directive['reason']; - $type = $reason['type']; - $info = match ($type) { - 'navigation' => $reason['navType'] - . '/' . ($reason['parent'] !== null ? ($reason['parent'] . '/') : '') - . $reason['name'], - 'dashlet' => $reason['pane'] . '/' . $reason['dashlet'], - 'module' => $reason['module'], - default => '-', - }; - foreach ($directive['directives'] as $directive => $policies) { - $this->add(static::tr([ - static::td($type), - static::td($info), - static::td($directive), - static::td(join(', ', $policies)), - ])); + $csp = iterator_to_array(Csp::collectDirectives(), false); + + $this->add(HtmlElement::create('h3', null, $this->translate('System'))); + $this->add($this->buildTable( + 'system', + $csp, + [t('Directive'), t('Value')], + function (array $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($directive), + $this->buildPolicy($policy), + ]); + }, + )); + + $this->add(HtmlElement::create('h3', null, $this->translate('Dashboards'))); + $this->add($this->buildTable( + 'dashlet', + $csp, + [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], + function (array $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($reason['pane']), + Table::td($reason['dashlet']), + Table::td($directive), + $this->buildPolicy($policy), + ]); } + )); + + // TODO: Handle other types of navigation in extra tables + $this->add(HtmlElement::create('h3', null, $this->translate('Navigation'))); + $this->add($this->buildTable( + 'navigation', + $csp, + [t('Type'), t('Name'), t('Parent'), t('Directive'), t('Value')], + function (array $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($reason['navType']), + Table::td($reason['name']), + Table::td($reason['parent'] ?? 'NA'), + Table::td($directive), + $this->buildPolicy($policy), + ]); + } + )); + + $this->add(HtmlElement::create('h3', null, $this->translate('Modules'))); + $this->add($this->buildTable( + 'module', + $csp, + [t('Module'), t('Directive'), t('Value')], + function (array $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($reason['module']), + Table::td($directive), + $this->buildPolicy($policy), + ]); + } + )); + } + + protected function getKeywordType(string $policy): ?string + { + $secureKeywords = [ + "'self'", + "'none'", + "'strict-dynamic'", + "'report-sample'", + "'report-sha256'", + "'report-sha384'", + "'report-sha512'", + ]; + + if (in_array($policy, $secureKeywords)) { + return 'secure'; + } + + $warningKeywords = [ + "'unsafe-inline'", + "'unsafe-eval'", + "'unsafe-hashes'", + ]; + + if (in_array($policy, $warningKeywords)) { + return 'warning'; + } + + return null; + } + + protected function getSchemeType(string $policy): ?string + { + if (! str_ends_with($policy, ':')) { + return null; + } + + if (str_contains($policy, ' ')) { + return null; + } + + $scheme = substr($policy, 0, -1); + + $secureSchemes = [ + 'https', + 'wss', + ]; + + if (in_array($scheme, $secureSchemes)) { + return 'secure'; + } + + $warningSchemes = [ + 'http', + 'ws', + 'blob', + 'data', + ]; + + if (in_array($scheme, $warningSchemes)) { + return 'warning'; + } + + return 'unknown'; + } + + protected function isNonce(string $policy): bool + { + return (str_starts_with($policy, "'nonce-") && str_ends_with($policy, "'")); + } + + protected function buildPolicy(string $policy): BaseHtmlElement + { + if ($policy === '*') { + $result = HtmlElement::create('span', ['class' => 'wildcard'], $policy); + } else if ($policy === "'self'") { + $result = HtmlElement::create('span', ['class' => 'self'], $policy); + } else if (($keyword = $this->getKeywordType($policy)) !== null) { + $result = HtmlElement::create( + 'span', ['class' => ['keyword', $keyword]], $policy + ); + } else if (($scheme = $this->getSchemeType($policy)) !== null) { + $result = HtmlElement::create( + 'span', ['class' => ['scheme', $scheme]], $policy + ); + } else if ($this->isNonce($policy)) { + $result = HtmlElement::create( + 'span', ['class' => 'nonce'], $policy + ); + } else if (filter_var($policy, FILTER_VALIDATE_URL) !== false) { + $result = HtmlElement::create( + 'a', + [ + 'href' => $policy, + 'class' => 'url', + 'target' => '_blank', + ], + $policy, + ); + } else { + $result = HtmlElement::create('span', null, $policy); } + return Table::td($result, ['class' => 'csp-policies']); } } From 67f14936eb7954f539ba99c7607962f48a9f7a4c 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 51/83] Hide tables with no content --- .../Web/Widget/CspConfigurationTable.php | 58 ++++++++++++------- 1 file changed, 36 insertions(+), 22 deletions(-) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index 46e05b2792..564422b4e5 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -21,18 +21,14 @@ public function __construct() $this->getAttributes()->add('class', 'csp-config-table'); } - protected function buildTable( + protected function addPolicyTable( + string $title, string $filterType, array $csp, array $header, callable $rowBuilder - ): Table { - $table = new Table(); - $headerRow = Table::tr(); - foreach ($header as $h) { - $headerRow->add(Table::th($h)); - } - $table->add($headerRow); + ): void { + $rows = []; foreach ($csp as $row) { $reason = $row['reason']; $type = $reason['type']; @@ -44,19 +40,37 @@ protected function buildTable( continue; } foreach ($policies as $k => $policy) { - $table->add($rowBuilder($reason, $directive, $policy)); + $rows[] = $rowBuilder($reason, $directive, $policy); } } } - return $table; + + if (count($rows) === 0) { + return; + } + + $this->add(HtmlElement::create('h3', null, $title)); + + $table = new Table(); + $headerRow = Table::tr(); + foreach ($header as $h) { + $headerRow->add(Table::th($h)); + } + $table->add($headerRow); + + foreach ($rows as $row) { + $table->add($row); + } + + $this->add($table); } protected function assemble(): void { $csp = iterator_to_array(Csp::collectDirectives(), false); - $this->add(HtmlElement::create('h3', null, $this->translate('System'))); - $this->add($this->buildTable( + $this->addPolicyTable( + t('System'), 'system', $csp, [t('Directive'), t('Value')], @@ -66,10 +80,10 @@ function (array $reason, string $directive, string $policy) { $this->buildPolicy($policy), ]); }, - )); + ); - $this->add(HtmlElement::create('h3', null, $this->translate('Dashboards'))); - $this->add($this->buildTable( + $this->addPolicyTable( + t('Dashboard'), 'dashlet', $csp, [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], @@ -81,11 +95,11 @@ function (array $reason, string $directive, string $policy) { $this->buildPolicy($policy), ]); } - )); + ); // TODO: Handle other types of navigation in extra tables - $this->add(HtmlElement::create('h3', null, $this->translate('Navigation'))); - $this->add($this->buildTable( + $this->addPolicyTable( + t('Navigation'), 'navigation', $csp, [t('Type'), t('Name'), t('Parent'), t('Directive'), t('Value')], @@ -98,10 +112,10 @@ function (array $reason, string $directive, string $policy) { $this->buildPolicy($policy), ]); } - )); + ); - $this->add(HtmlElement::create('h3', null, $this->translate('Modules'))); - $this->add($this->buildTable( + $this->addPolicyTable( + t('Modules'), 'module', $csp, [t('Module'), t('Directive'), t('Value')], @@ -112,7 +126,7 @@ function (array $reason, string $directive, string $policy) { $this->buildPolicy($policy), ]); } - )); + ); } protected function getKeywordType(string $policy): ?string From 7160113bcc9e7ded02fd2fece166c40e9c815ae1 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 52/83] Use Link widget --- library/Icinga/Web/Widget/CspConfigurationTable.php | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index 564422b4e5..83c23079cb 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -9,6 +9,7 @@ use ipl\Html\HtmlElement; use ipl\Html\Table; use ipl\I18n\Translation; +use ipl\Web\Widget\Link; class CspConfigurationTable extends BaseHtmlElement { @@ -217,15 +218,7 @@ protected function buildPolicy(string $policy): BaseHtmlElement 'span', ['class' => 'nonce'], $policy ); } else if (filter_var($policy, FILTER_VALIDATE_URL) !== false) { - $result = HtmlElement::create( - 'a', - [ - 'href' => $policy, - 'class' => 'url', - 'target' => '_blank', - ], - $policy, - ); + $result = new Link($policy, $policy, ['target' => '_blank']); } else { $result = HtmlElement::create('span', null, $policy); } From 02c4a5c5bde92e698b1b7458ad8974085e4b9385 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 53/83] 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 +++++++++++++++++++ 5 files changed, 111 insertions(+), 7 deletions(-) create mode 100644 public/css/icinga/csp-config-editor.less diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index ed363f95db..e117adc9bc 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -127,12 +127,6 @@ public function generalAction() $cspForm->handleRequest(ServerRequest::fromGlobals()); $this->view->cspForm = $cspForm; - if ($cspForm->isCspEnabled()) { - $this->view->cspTable = (new CspConfigurationTable())->render(); - } else { - $this->view->cspTable = ''; - } - $this->createApplicationTabs()->activate('general'); } diff --git a/application/forms/Config/General/CspConfigForm.php b/application/forms/Config/General/CspConfigForm.php index 6402f7dff7..d161e5dd59 100644 --- a/application/forms/Config/General/CspConfigForm.php +++ b/application/forms/Config/General/CspConfigForm.php @@ -7,6 +7,8 @@ use Icinga\Application\Config; use Icinga\Util\Csp; use Icinga\Web\Session; +use Icinga\Web\Widget\CspConfigurationTable; +use ipl\Html\HtmlElement; use ipl\Web\Common\CalloutType; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Common\FormUid; @@ -23,6 +25,7 @@ class CspConfigForm extends CompatForm public function __construct(protected Config $config) { $this->setAttribute("name", "csp_config"); + $this->getAttributes()->add("class", "csp-config-form"); $this->applyDefaultElementDecorators(); } @@ -96,6 +99,16 @@ protected function assemble(): void 'disabled' => true, 'value' => Csp::getAutomaticHeaderValue(), ]); + + + $this->add(HtmlElement::create( + 'div', + [ + 'class' => 'collapsible', + 'data-visible-height' => 250, + ], + new CspConfigurationTable(), + )); } } diff --git a/application/views/scripts/config/general.phtml b/application/views/scripts/config/general.phtml index ecb387c8d5..a5ab32b786 100644 --- a/application/views/scripts/config/general.phtml +++ b/application/views/scripts/config/general.phtml @@ -7,5 +7,4 @@

translate('Content Security Policy') ?>

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

translate('General') ?>

- -

translate('Content Security Policy') ?>

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

translate('Content Security Policy') ?>

+ +
From 1a60be204a9dd167787c258fd9addf7783d2b4e2 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 62/83] 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 eb41e480a4..cc2e4509c7 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -125,7 +125,7 @@ public function generalAction() * * @throws SecurityException If the user lacks the permission for configuring the security configuration */ - public function securityAction() + public function securityAction(): void { $this->assertPermission('config/security'); @@ -140,9 +140,8 @@ public function securityAction() 'include_user_content' => $config->get('security', 'include_user_content'), ]); - $wasCspEnabled = Csp::isEnabled(); - $cspForm->on(ContractForm::ON_SUBMIT, function (CspConfigForm $form) use ($config, $wasCspEnabled) { - if ($form->hasConfigChanged() && ($form->isCspEnabled() || $wasCspEnabled)) { + $cspForm->on(ContractForm::ON_SUBMIT, function (CspConfigForm $form) use ($config) { + if ($form->hasConfigChanged()) { $this->getResponse()->setReloadWindow(true); } }); diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 38c4baa5df..e259967f84 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -116,7 +116,7 @@ protected function assemble(): void . ' Enable Custom CSP checkbox above.', ), 'disabled' => true, - 'value' => Csp::getAutomaticHeaderValue($this->shouldIncludeUserContent()), + 'value' => Csp::getAutomaticHeader($this->shouldIncludeUserContent())->getHeader(), ]); $this->add(HtmlElement::create( From 15267292682ffe819aa713e1fa31af14f3977335 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 63/83] Use new Csp class in ipl-web --- library/Icinga/Security/Csp/LoadedCsp.php | 22 ++ .../Icinga/Security/Csp/Loader/CspLoader.php | 20 ++ .../Csp/Loader/DashboardCspLoader.php | 70 +++++ .../Security/Csp/Loader/ModuleCspLoader.php | 50 +++ .../Csp/Loader/NavigationCspLoader.php | 80 +++++ .../Security/Csp/Loader/StaticCspLoader.php | 35 +++ .../Icinga/Security/Csp/Reason/CspReason.php | 12 + .../Csp/Reason/DashboardCspReason.php | 24 ++ .../Security/Csp/Reason/ModuleCspReason.php | 19 ++ .../Csp/Reason/NavigationCspReason.php | 19 ++ .../Security/Csp/Reason/StaticCspReason.php | 19 ++ library/Icinga/Util/Csp.php | 287 +++--------------- .../Web/Widget/CspConfigurationTable.php | 98 +++--- 13 files changed, 473 insertions(+), 282 deletions(-) create mode 100644 library/Icinga/Security/Csp/LoadedCsp.php create mode 100644 library/Icinga/Security/Csp/Loader/CspLoader.php create mode 100644 library/Icinga/Security/Csp/Loader/DashboardCspLoader.php create mode 100644 library/Icinga/Security/Csp/Loader/ModuleCspLoader.php create mode 100644 library/Icinga/Security/Csp/Loader/NavigationCspLoader.php create mode 100644 library/Icinga/Security/Csp/Loader/StaticCspLoader.php create mode 100644 library/Icinga/Security/Csp/Reason/CspReason.php create mode 100644 library/Icinga/Security/Csp/Reason/DashboardCspReason.php create mode 100644 library/Icinga/Security/Csp/Reason/ModuleCspReason.php create mode 100644 library/Icinga/Security/Csp/Reason/NavigationCspReason.php create mode 100644 library/Icinga/Security/Csp/Reason/StaticCspReason.php diff --git a/library/Icinga/Security/Csp/LoadedCsp.php b/library/Icinga/Security/Csp/LoadedCsp.php new file mode 100644 index 0000000000..5fdee5de4b --- /dev/null +++ b/library/Icinga/Security/Csp/LoadedCsp.php @@ -0,0 +1,22 @@ +getUser(); + if ($user === null) { + throw new RuntimeException('No user logged in'); + } + + $dashboard = new Dashboard(); + $dashboard->setUser($user); + $dashboard->load(); + + $result = []; + + /** @var Dashboard\Pane $pane */ + foreach ($dashboard->getPanes() as $pane) { + /** @var Dashboard\Dashlet $dashlet */ + foreach ($pane->getDashlets() as $dashlet) { + $url = $dashlet->getUrl(); + if ($url === null) { + continue; + } + + $absoluteUrl = $url->isExternal() + ? $url->getAbsoluteUrl() + : $url->getParam('url'); + if ($absoluteUrl === null || filter_var($absoluteUrl, FILTER_VALIDATE_URL) === false) { + continue; + } + + $absoluteUrl = Url::fromPath($absoluteUrl); + + $cspUrl = $absoluteUrl->getScheme() . '://' . $absoluteUrl->getHost(); + if (($port = $absoluteUrl->getPort()) !== null) { + $cspUrl .= ':' . $port; + } + + $csp = new LoadedCsp(new DashboardCspReason($pane, $dashlet)); + $csp->add('frame-src', $cspUrl); + $result[] = $csp; + } + } + + return $result; + } +} diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php new file mode 100644 index 0000000000..39c4f4e2c0 --- /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::error('Failed to CSP hook on request: %s', $e); + } + } + + return $result; + } +} diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php new file mode 100644 index 0000000000..81fb9c0862 --- /dev/null +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -0,0 +1,80 @@ +isAuthenticated()) { + throw new RuntimeException('No user logged in'); + } + + $navigationType = Navigation::getItemTypeConfiguration(); + foreach ($navigationType as $type => $_) { + $navigation = new Navigation(); + foreach ($navigation->load($type) as $rootItem) { + foreach (self::yieldNavigation($rootItem) as $item) { + $url = $item->getUrl(); + $cspUrl = $url->getScheme() . '://' . $url->getHost(); + if (($port = $url->getPort()) !== null) { + $cspUrl .= ':' . $port; + } + + $csp = new LoadedCsp(new NavigationCspReason($type, $item)); + $csp->add('frame-src', $cspUrl); + $result[] = $csp; + } + } + } + + return $result; + } + + /** + * Recursively yield all navigation items that have an external URL. + * + * @param NavigationItem $item The top-level navigation item to start from. + * @return Generator + */ + protected static function yieldNavigation(NavigationItem $item): Generator + { + if ($item->hasChildren()) { + foreach ($item as $child) { + yield from self::yieldNavigation($child); + } + } + + $url = $item->getUrl(); + if ($url === null) { + return; + } + if ($item->getTarget() !== '_blank' && $url->isExternal()) { + yield $item; + } + } +} diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php new file mode 100644 index 0000000000..4370ba00c0 --- /dev/null +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -0,0 +1,35 @@ +name)); + foreach ($this->directives as $directive => $values) { + $csp->add($directive, $values); + } + + return [$csp]; + } +} diff --git a/library/Icinga/Security/Csp/Reason/CspReason.php b/library/Icinga/Security/Csp/Reason/CspReason.php new file mode 100644 index 0000000000..0aa546843d --- /dev/null +++ b/library/Icinga/Security/Csp/Reason/CspReason.php @@ -0,0 +1,12 @@ +styleNonce)) { + throw new RuntimeException('No nonce set for CSS'); + } + + $result = []; + $result = array_merge($result, (new StaticCspLoader( + 'system', + [ +// 'default-src' => ["'self'"], + 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], + 'font-src' => ["'self'", "data:"], + 'img-src' => ["'self'", "data:"], + 'frame-src' => ["'self'"], + ] + ))->load()); + + $result = array_merge($result, (new ModuleCspLoader())->load()); + if ($includeUserContent === null) { $includeUserContent = Config::app()->get('security', 'include_user_content', '0') === '1'; } - yield from self::yieldSystemOrigins(); - yield from self::yieldModuleOrigins(); + if ($includeUserContent) { - yield from self::yieldNavigationOrigins(); - yield from self::yieldDashletOrigins(); + $result = array_merge($result, (new DashboardCspLoader())->load()); + $result = array_merge($result, (new NavigationCspLoader())->load()); } + + return $result; } /** @@ -96,19 +108,19 @@ public static function getHeader(): string { $config = Config::app(); if ($config->get('security', 'use_custom_csp', '0') === '1') { - return self::getCustomHeaderValue(); + return self::getCustomHeader(); } - return self::getAutomaticHeaderValue(); + return self::getAutomaticHeader(); } /** * Get the custom Content-Security-Policy set in the config. * This method automatically replaces new-lines and the {style_nonce} placeholder with the generated nonce. * - * @return string Returns the custom CSP header value. + * @return CspInstance Returns the custom CSP header. */ - protected static function getCustomHeaderValue(): string + protected static function getCustomHeader(): CspInstance { $csp = static::getInstance(); @@ -122,37 +134,19 @@ protected static function getCustomHeaderValue(): string $customCsp = str_replace("\n", ' ', $customCsp); $customCsp = str_replace('{style_nonce}', "'nonce-{$csp->styleNonce}'", $customCsp); - return $customCsp; + return CspInstance::fromString($customCsp); } /** * Get the automatically generated Content-Security-Policy. * - * @return string Returns the generated header value. + * @return CspInstance Returns the generated header value. * @throws RuntimeException If no nonce set for CSS */ - public static function getAutomaticHeaderValue(?bool $includeUserContent = null): string + public static function getAutomaticHeader(?bool $includeUserContent = null): CspInstance { - $cspDirectives = []; - foreach (self::collectDirectives($includeUserContent) as $directive) { - foreach ($directive['directives'] as $directive => $policies) { - if (! isset($cspDirectives[$directive])) { - $cspDirectives[$directive] = []; - } - $cspDirectives[$directive] = array_merge($cspDirectives[$directive], $policies); - } - } - - $headerSegments = []; - foreach ($cspDirectives as $directive => $directivePolicies) { - if (empty($directivePolicies)) { - continue; - } - - $headerSegments[] = implode(' ', array_merge([$directive], array_unique($directivePolicies))); - } - - return implode('; ', $headerSegments); + $csps = self::load($includeUserContent); + return CspInstance::merge(...$csps); } /** @@ -209,203 +203,4 @@ protected static function getInstance(): self return static::$instance; } - - /** - * Yields the system origins. - * These directives should always be added first. - * - * @return Generator - */ - protected static function yieldSystemOrigins(): Generator - { - $csp = static::getInstance(); - - if (empty($csp->styleNonce)) { - throw new RuntimeException('No nonce set for CSS'); - } - - $items = [ - 'default-src' => ["'self'"], - 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], - 'font-src' => ["'self'", "data:"], - 'img-src' => ["'self'", "data:"], - 'frame-src' => ["'self'"], - ]; - - foreach ($items as $directive => $policies) { - yield [ - 'directives' => [ - $directive => $policies, - ], - 'reason' => [ - 'type' => 'system', - ] - ]; - } - } - - /** - * Yield all CSP directives from modules. See {@see CspDirectiveHook} for details. - * @return Generator - */ - protected static function yieldModuleOrigins(): Generator - { - // Allow modules to add their own csp directives in a limited fashion. - foreach (CspDirectiveHook::all() as $hook) { - $directives = []; - try { - foreach ($hook->getCspDirectives() as $directive => $policies) { - // policy names contain only lowercase letters and '-'. Reject anything else. - if (! preg_match('|^[a-z\-]+$|', $directive)) { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Invalid CSP directive found: $directive"); - continue; - } - - // The default-src can only ever be 'self'. Disallow any updates to it. - if ($directive === "default-src") { - $errorSource = get_class($hook); - Logger::debug("$errorSource: Changing default-src is forbidden."); - continue; - } - - if (count($policies) === 0) { - continue; - } - - $directives[$directive] = $policies; - } - - if (count($directives) === 0) { - continue; - } - - yield [ - 'directives' => $directives, - 'reason' => [ - 'type' => 'module', - 'module' => ClassLoader::extractModuleName(get_class($hook)), - ], - ]; - } catch (Throwable $e) { - Logger::error('Failed to CSP hook on request: %s', $e); - } - } - } - - /** - * Fetches navigation items for the current user. - * - * Iterates through all registered navigation types, loads both user-specific - * and shared configurations, and returns a list of menu items. - * - * @return Generator A list of CSP directives, one for each navigation-item that has an external URL. - */ - protected static function yieldNavigationOrigins(): Generator - { - $auth = Auth::getInstance(); - if (! $auth->isAuthenticated()) { - return; - } - - $navigationType = Navigation::getItemTypeConfiguration(); - foreach ($navigationType as $type => $_) { - $navigation = new Navigation(); - foreach ($navigation->load($type) as $navItem) { - foreach (self::yieldNavigation($navItem) as $name => $url) { - $cspUrl = $url->getScheme() . '://' . $url->getHost(); - if (($port = $url->getPort()) !== null) { - $cspUrl .= ':' . $port; - } - yield [ - 'directives' => [ - 'frame-src' => [$cspUrl], - ], - 'reason' => [ - 'type' => 'navigation', - 'name' => $name, - 'parent' => $name !== $navItem->getName() ? $navItem->getName() : null, - 'navType' => $type, - ] - ]; - } - } - } - } - - /** - * Recursively yield all navigation items that have an external URL. - * - * @param NavigationItem $item The top-level navigation item to start from. - * @return Generator - */ - protected static function yieldNavigation(NavigationItem $item): Generator - { - if ($item->hasChildren()) { - foreach ($item as $child) { - yield from self::yieldNavigation($child); - } - } - - $url = $item->getUrl(); - if ($url === null) { - return; - } - if ($item->getTarget() !== '_blank' && $url->isExternal()) { - yield $item->getName() => $item->getUrl(); - } - } - - /** - * Fetches all dashlets for the current user that have an external URL. - * - * @return Generator A list of CSP directives, one for each dashlet that has an external URL. - */ - protected static function yieldDashletOrigins(): Generator - { - $user = Auth::getInstance()->getUser(); - if ($user === null) { - return; - } - - $dashboard = new Dashboard(); - $dashboard->setUser($user); - $dashboard->load(); - - /** @var Dashboard\Pane $pane */ - foreach ($dashboard->getPanes() as $pane) { - /** @var Dashboard\Dashlet $dashlet */ - foreach ($pane->getDashlets() as $dashlet) { - $url = $dashlet->getUrl(); - if ($url === null) { - continue; - } - - $absoluteUrl = $url->isExternal() - ? $url->getAbsoluteUrl() - : $url->getParam('url'); - if ($absoluteUrl === null || filter_var($absoluteUrl, FILTER_VALIDATE_URL) === false) { - continue; - } - - $absoluteUrl = Url::fromPath($absoluteUrl); - - $cspUrl = $absoluteUrl->getScheme() . '://' . $absoluteUrl->getHost(); - if (($port = $absoluteUrl->getPort()) !== null) { - $cspUrl .= ':' . $port; - } - - yield [ - 'directives' => [ - 'frame-src' => [$cspUrl], - ], - 'reason' => [ - 'type' => 'dashlet', - 'pane' => $pane->getName(), - 'dashlet' => $dashlet->getName(), - ] - ]; - } - } - } } diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index efaf8da011..0747eb352b 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -4,6 +4,12 @@ namespace Icinga\Web\Widget; +use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\Reason\CspReason; +use Icinga\Security\Csp\Reason\DashboardCspReason; +use Icinga\Security\Csp\Reason\ModuleCspReason; +use Icinga\Security\Csp\Reason\NavigationCspReason; +use Icinga\Security\Csp\Reason\StaticCspReason; use Icinga\Util\Csp; use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlElement; @@ -71,26 +77,31 @@ public function __construct( ) { } + /** + * @param string $title + * @param callable $filter + * @param LoadedCsp[] $csps + * @param array $header + * @param callable $rowBuilder + * + * @return void + */ protected function addPolicyTable( string $title, - string $filterType, - array $csp, + callable $filter, + array $csps, array $header, - callable $rowBuilder + callable $rowBuilder, ): void { $rows = []; - foreach ($csp as $row) { - $reason = $row['reason']; - $type = $reason['type']; - if ($type !== $filterType) { + foreach ($csps as $csp) { + if (! $filter($csp->loadReason)) { continue; } - foreach ($row['directives'] as $directive => $policies) { - if (count($policies) === 0) { - continue; - } - foreach ($policies as $k => $policy) { - $rows[] = $rowBuilder($reason, $directive, $policy); + foreach ($csp->getDirectives() as $directive => $policies) + { + foreach ($policies as $policy) { + $rows[] = $rowBuilder($csp->loadReason, $directive, $policy); } } } @@ -117,14 +128,17 @@ protected function addPolicyTable( protected function assemble(): void { - $csp = iterator_to_array(Csp::collectDirectives($this->includeUserContent), false); + $csps = Csp::load($this->includeUserContent); $this->addPolicyTable( t('System'), - 'system', - $csp, + function (CspReason $reason) { + return $reason instanceof StaticCspReason + && $reason->name === 'system'; + }, + $csps, [t('Directive'), t('Value')], - function (array $reason, string $directive, string $policy) { + function (StaticCspReason $reason, string $directive, string $policy) { return Table::tr([ Table::td($directive), $this->buildPolicy($directive, $policy), @@ -134,48 +148,60 @@ function (array $reason, string $directive, string $policy) { $this->addPolicyTable( t('Dashboard'), - 'dashlet', - $csp, + function (CspReason $reason) { + return $reason instanceof DashboardCspReason; + }, + $csps, [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], - function (array $reason, string $directive, string $policy) { + function (DashboardCspReason $reason, string $directive, string $policy) { return Table::tr([ - Table::td($reason['pane']), - Table::td($reason['dashlet']), + Table::td($reason->pane->getName()), + Table::td($reason->dashlet->getName()), Table::td($directive), $this->buildPolicy($directive, $policy), ]); - } + }, ); // TODO: Handle other types of navigation in extra tables $this->addPolicyTable( t('Navigation'), - 'navigation', - $csp, - [t('Type'), t('Name'), t('Parent'), t('Directive'), t('Value')], - function (array $reason, string $directive, string $policy) { + function (CspReason $reason) { + return $reason instanceof NavigationCspReason; + }, + $csps, + [t('Type'), t('Parent'), t('Name'), t('Directive'), t('Value')], + function (NavigationCspReason $reason, string $directive, string $policy) { + $parent = $reason->item->getParent(); + if ($parent === null) { + $parentCell = Table::td(t('None'))->setAttribute('class', 'empty-state'); + } else { + $parentCell = Table::td($parent->getName()); + } return Table::tr([ - Table::td($reason['navType']), - Table::td($reason['name']), - Table::td($reason['parent'] ?? t('NA')), + Table::td($reason->type), + $parentCell, + Table::td($reason->item->getName()), Table::td($directive), $this->buildPolicy($directive, $policy), ]); - } + }, ); $this->addPolicyTable( t('Modules'), - 'module', - $csp, + function (CspReason $reason) { + return $reason instanceof ModuleCspReason; + }, + $csps, [t('Module'), t('Directive'), t('Value')], - function (array $reason, string $directive, string $policy) { + function (ModuleCspReason $reason, string $directive, string $policy) { return Table::tr([ - Table::td($reason['module']), + Table::td($reason->module), Table::td($directive), $this->buildPolicy($directive, $policy), ]); - } + }, ); } From 100dcfe77fb80a53c8c530265ca95ddf66ba573f 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 64/83] 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 0747eb352b..e03b4e1d9c 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -257,15 +257,15 @@ protected function isNonce(string $policy): bool protected function buildPolicy(string $directive, string $policy): BaseHtmlElement { if ($policy === '*') { - $result = HtmlElement::create('span', ['class' => 'wildcard'], $policy); + $result = HtmlElement::create('span', ['class' => 'csp-wildcard'], $policy); } elseif ($policy === "'self'") { - $result = HtmlElement::create('span', ['class' => 'self'], $policy); + $result = HtmlElement::create('span', ['class' => 'csp-self'], $policy); } elseif (($keyword = $this->getKeywordType($policy)) !== null) { - $result = HtmlElement::create('span', ['class' => ['keyword', $keyword]], $policy); + $result = HtmlElement::create('span', ['class' => ['csp-keyword', 'csp-' . $keyword]], $policy); } elseif (($scheme = $this->getSchemeType($directive, $policy)) !== null) { - $result = HtmlElement::create('span', ['class' => ['scheme', $scheme]], $policy); + $result = HtmlElement::create('span', ['class' => ['csp-scheme', 'csp-' . $scheme]], $policy); } elseif ($this->isNonce($policy)) { - $result = HtmlElement::create('span', ['class' => 'nonce'], $policy); + $result = HtmlElement::create('span', ['class' => 'csp-nonce'], $policy); } elseif (filter_var($policy, FILTER_VALIDATE_URL) !== false) { $result = new Link($policy, $policy, ['target' => '_blank']); } else { diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less index 13c33bf30d..228da7394c 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -57,24 +57,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 9aeb8a81267256d7137aafd9f8a649af4bc459ff 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 65/83] Code style changes --- library/Icinga/Security/Csp/Loader/CspLoader.php | 2 +- library/Icinga/Util/Csp.php | 10 +++++----- library/Icinga/Web/Widget/CspConfigurationTable.php | 3 +-- 3 files changed, 7 insertions(+), 8 deletions(-) diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php index 3f804b7f07..686019c56f 100644 --- a/library/Icinga/Security/Csp/Loader/CspLoader.php +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -16,5 +16,5 @@ abstract class CspLoader * * @return LoadedCsp[] */ - public abstract function load(): array; + abstract public function load(): array; } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 1c9b32ea4a..57495ae5ba 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -76,11 +76,11 @@ public static function load(?bool $includeUserContent = null): array $result = array_merge($result, (new StaticCspLoader( 'system', [ -// 'default-src' => ["'self'"], - 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], - 'font-src' => ["'self'", "data:"], - 'img-src' => ["'self'", "data:"], - 'frame-src' => ["'self'"], + /* There is no need to define `default-src` here, as it is already defined in the base CSP */ + 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], + 'font-src' => ["'self'", "data:"], + 'img-src' => ["'self'", "data:"], + 'frame-src' => ["'self'"], ] ))->load()); diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php index e03b4e1d9c..72f74a265a 100644 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ b/library/Icinga/Web/Widget/CspConfigurationTable.php @@ -98,8 +98,7 @@ protected function addPolicyTable( if (! $filter($csp->loadReason)) { continue; } - foreach ($csp->getDirectives() as $directive => $policies) - { + foreach ($csp->getDirectives() as $directive => $policies) { foreach ($policies as $policy) { $rows[] = $rowBuilder($csp->loadReason, $directive, $policy); } From e99fa2be7d7681a7cff3b6858db6e242b38fde1a 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 66/83] Rework Csp to no longer rely on a private instance just to store the nonce --- library/Icinga/Util/Csp.php | 76 +++++++++++-------------------------- 1 file changed, 23 insertions(+), 53 deletions(-) diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 57495ae5ba..dc69f630f0 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -16,7 +16,6 @@ use Icinga\Web\Window; use ipl\Web\Common\Csp as CspInstance; use RuntimeException; -use function ipl\Stdlib\get_php_type; /** * Helper to enable strict content security policy (CSP) @@ -31,11 +30,8 @@ */ class Csp { - /** @var self|null */ - protected static ?self $instance = null; - - /** @var ?string */ - protected ?string $styleNonce = null; + /** @var CspInstance|null */ + protected static ?CspInstance $csp = null; /** Singleton */ private function __construct() @@ -59,7 +55,7 @@ public static function addHeader(Response $response): void public static function isEnabled(): bool { - return Config::app()->get('security', 'use_strict_csp', '0') === '1'; + return Config::app()->get('security', 'use_strict_csp'); } /** @@ -67,8 +63,8 @@ public static function isEnabled(): bool */ public static function load(?bool $includeUserContent = null): array { - $csp = static::getInstance(); - if (empty($csp->styleNonce)) { + $nonce = static::getStyleNonce(); + if (empty($nonce)) { throw new RuntimeException('No nonce set for CSS'); } @@ -77,7 +73,7 @@ public static function load(?bool $includeUserContent = null): array 'system', [ /* There is no need to define `default-src` here, as it is already defined in the base CSP */ - 'style-src' => ["'self'", "'nonce-{$csp->styleNonce}'"], + 'style-src' => ["'self'", "'nonce-{$nonce}'"], 'font-src' => ["'self'", "data:"], 'img-src' => ["'self'", "data:"], 'frame-src' => ["'self'"], @@ -106,12 +102,16 @@ public static function load(?bool $includeUserContent = null): array */ public static function getHeader(): string { - $config = Config::app(); - if ($config->get('security', 'use_custom_csp', '0') === '1') { - return self::getCustomHeader(); + if (static::$csp === null) { + $config = Config::app(); + if ($config->get('security', 'use_custom_csp')) { + static::$csp = self::getCustomHeader(); + } else { + static::$csp = self::getAutomaticHeader(); + } } - return self::getAutomaticHeader(); + return static::$csp->getHeader(); } /** @@ -122,17 +122,14 @@ public static function getHeader(): string */ protected static function getCustomHeader(): CspInstance { - $csp = static::getInstance(); - - if (empty($csp->styleNonce)) { + $nonce = static::getStyleNonce(); + if (empty($nonce)) { throw new RuntimeException('No nonce set for CSS'); } $config = Config::app(); $customCsp = $config->get('security', 'custom_csp', ''); - $customCsp = str_replace("\r\n", ' ', $customCsp); - $customCsp = str_replace("\n", ' ', $customCsp); - $customCsp = str_replace('{style_nonce}', "'nonce-{$csp->styleNonce}'", $customCsp); + $customCsp = str_replace('{style_nonce}', "'nonce-{$nonce}'", $customCsp); return CspInstance::fromString($customCsp); } @@ -157,10 +154,9 @@ public static function getAutomaticHeader(?bool $includeUserContent = null): Csp */ public static function createNonce(): void { - $csp = static::getInstance(); - if ($csp->styleNonce === null) { - $csp->styleNonce = base64_encode(random_bytes(16)); - Window::getInstance()->getSessionNamespace('csp')->set('style_nonce', $csp->styleNonce); + if (Window::getInstance()->getSessionNamespace('csp')->get('style_nonce') === null) { + $nonce = base64_encode(random_bytes(16)); + Window::getInstance()->getSessionNamespace('csp')->set('style_nonce', $nonce); } } @@ -171,36 +167,10 @@ public static function createNonce(): void */ public static function getStyleNonce(): ?string { - if (Icinga::app()->isWeb()) { - return static::getInstance()->styleNonce; - } - return null; - } - - /** - * Get the CSP instance - * - * @return self - */ - protected static function getInstance(): self - { - if (static::$instance === null) { - $csp = new static(); - $nonce = Window::getInstance()->getSessionNamespace('csp')->get('style_nonce'); - if ($nonce !== null && ! is_string($nonce)) { - throw new RuntimeException( - sprintf( - 'Nonce value is expected to be string, got %s instead', - get_php_type($nonce), - ), - ); - } - - $csp->styleNonce = $nonce; - - static::$instance = $csp; + if (Icinga::app()->isWeb() && static::$csp !== null) { + return static::$csp->getNonce(); } - return static::$instance; + return Window::getInstance()->getSessionNamespace('csp')->get('style_nonce'); } } From 650f932cd32335daf74e20ade0633aa995b9788d 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 67/83] Add form validation --- .../forms/Config/Security/CspConfigForm.php | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index e259967f84..27b239204d 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -4,12 +4,15 @@ namespace Icinga\Forms\Config\Security; +use Exception; use Icinga\Application\Config; use Icinga\Util\Csp; use Icinga\Web\Session; use Icinga\Web\Widget\CspConfigurationTable; use ipl\Html\HtmlElement; +use ipl\Validator\CallbackValidator; use ipl\Web\Common\CalloutType; +use ipl\Web\Common\Csp as CspInstance; use ipl\Web\Common\CsrfCounterMeasure; use ipl\Web\Common\FormUid; use ipl\Web\Compat\CompatForm; @@ -88,6 +91,23 @@ protected function assemble(): void 'Set a custom CSP-Header. This completely overrides the automatically generated one.' . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.', ), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator) { + if (empty($value)) { + return true; + } + + try { + $value = str_replace('{style_nonce}', "'nonce-validation'", $value); + CspInstance::fromString($value); + } catch (Exception $e) { + $validator->addMessage($e->getMessage()); + return false; + } + + return true; + }), + ] ]); } else { $this->addElement('hidden', 'custom_csp'); From 9853a5b13217bb2cc516401982f7040f55668e4e 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 68/83] 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 | 529 +++++++++++++++--- library/Icinga/Util/Csp.php | 22 +- .../Web/Widget/CspConfigurationTable.php | 275 --------- public/css/icinga/csp-config-editor.less | 70 ++- 5 files changed, 521 insertions(+), 379 deletions(-) delete mode 100644 library/Icinga/Web/Widget/CspConfigurationTable.php diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index cc2e4509c7..400c429ccd 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -137,7 +137,9 @@ public function securityAction(): void 'use_strict_csp' => Csp::isEnabled(), 'use_custom_csp' => $config->get('security', 'use_custom_csp'), '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) use ($config) { diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 27b239204d..2644a973d5 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -6,10 +6,20 @@ use Exception; use Icinga\Application\Config; +use Icinga\Data\ConfigObject; +use Icinga\Security\Csp\LoadedCsp; +use Icinga\Security\Csp\Reason\CspReason; +use Icinga\Security\Csp\Reason\DashboardCspReason; +use Icinga\Security\Csp\Reason\ModuleCspReason; +use Icinga\Security\Csp\Reason\NavigationCspReason; +use Icinga\Security\Csp\Reason\StaticCspReason; use Icinga\Util\Csp; use Icinga\Web\Session; -use Icinga\Web\Widget\CspConfigurationTable; +use ipl\Html\Attributes; +use ipl\Html\BaseHtmlElement; use ipl\Html\HtmlElement; +use ipl\Html\Table; +use ipl\Html\Text; use ipl\Validator\CallbackValidator; use ipl\Web\Common\CalloutType; use ipl\Web\Common\Csp as CspInstance; @@ -17,23 +27,86 @@ use ipl\Web\Common\FormUid; use ipl\Web\Compat\CompatForm; use ipl\Web\Widget\Callout; +use ipl\Web\Widget\Icon; +use ipl\Web\Widget\Link; class CspConfigForm extends CompatForm { use FormUid; use CsrfCounterMeasure; + /** @var string[] */ + protected const SECURE_KEYWORDS = [ + "'self'", + "'none'", + "'strict-dynamic'", + "'report-sample'", + "'report-sha256'", + "'report-sha384'", + "'report-sha512'", + ]; + + /** @var string[] */ + protected const WARNING_KEYWORDS = [ + "'unsafe-inline'", + "'unsafe-eval'", + "'unsafe-hashes'", + ]; + + /** @var string[] */ + protected const SECURE_SCHEMAS = [ + 'https', + 'wss', + ]; + + /** @var string[] */ + protected const WARNING_SCHEMAS = [ + 'http', + 'ws', + 'blob', + ]; + + /** @var string[] */ + protected const CRITICAL_DATA_DIRECTIVES = [ + 'default-src', + 'script-src', + 'object-src', + 'frame-src', + ]; + + /** @var string[] */ + protected const WARNING_DATA_DIRECTIVES = [ + 'style-src', + 'worker-src', + 'child-src', + 'base-uri', + ]; + + /** + * The number of rows for the CUSTOMS CSP textarea + * + * @const int + */ + protected const TEXTAREA_ROWS = 8; + protected bool $changed = false; public function __construct(protected Config $config) { - $this->setAttribute("name", "csp_config"); - $this->getAttributes()->add("class", "csp-config-form"); + $this->setAttribute('name', 'csp_config'); + $this->getAttributes()->add('class', 'csp-config-form'); $this->applyDefaultElementDecorators(); } protected function assemble(): void { + Csp::createNonce(); + $csps = Csp::load(new ConfigObject([ + 'csp_enable_modules' => '1', + 'csp_enable_dashboards' => '1', + 'csp_enable_navigation' => '1', + ])); + $this->addElement($this->createUidElement()); $this->addCsrfCounterMeasure(Session::getSession()->getId()); @@ -53,11 +126,141 @@ protected function assemble(): void ], ); + $disabledState = $this->getPopulatedValue('use_custom_csp') === '1'; + $disabledClass = $disabledState ? 'csp-disabled' : ''; + + $this->add(HtmlElement::create( + 'p', + ['class' => ['csp-form-hint', $disabledClass]], + $this->translate( + 'Enabling CSP will block some requests and prevent some functionality from working as expected.' + ), + )); + if (! $this->isCspEnabled()) { $this->addElement('hidden', 'use_custom_csp'); $this->addElement('hidden', 'custom_csp'); - $this->addElement('hidden', 'include_user_content'); + $this->addElement('hidden', 'csp_enable_modules'); + $this->addElement('hidden', 'csp_enable_dashboards'); + $this->addElement('hidden', 'csp_enable_navigation'); } else { + $this->add(HtmlElement::create( + 'h3', + ['class' => ['csp-form-hint', $disabledClass]], + $this->translate('Allowed Sources'), + )); + + $this->add(HtmlElement::create( + 'p', + ['class' => ['csp-form-hint', $disabledClass]], + $this->translate( + 'Sources that are used in the generation of the CSP-Header.' + ), + )); + + $this->addPolicyTable( + t('System'), + null, + null, + ! $disabledState, + function (CspReason $reason) { + return $reason instanceof StaticCspReason + && $reason->name === 'system'; + }, + $csps, + [t('Directive'), t('Value')], + function (StaticCspReason $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($directive), + $this->buildPolicy($directive, $policy), + ]); + }, + ); + + $this->addPolicyTable( + t('Modules'), + $this->translate( + 'Should module defined csp directives be enabled?' + . ' Note: Modules can define or change csp directives at any point.' + ), + 'csp_enable_modules', + ! $disabledState, + function (CspReason $reason) { + return $reason instanceof ModuleCspReason; + }, + $csps, + [t('Module'), t('Directive'), t('Value')], + function (ModuleCspReason $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($reason->module), + Table::td($directive), + $this->buildPolicy($directive, $policy), + ]); + }, + ); + + $this->addPolicyTable( + t('Dashboard'), + $this->translate( + 'Enable user defined dashboards. Note: You will only be able to see your own dashboards,' + . ' and there is currently no way to see what others have configured for themselves.' + ), + 'csp_enable_dashboards', + ! $disabledState, + function (CspReason $reason) { + return $reason instanceof DashboardCspReason; + }, + $csps, + [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], + function (DashboardCspReason $reason, string $directive, string $policy) { + return Table::tr([ + Table::td($reason->pane->getName()), + Table::td($reason->dashlet->getName()), + Table::td($directive), + $this->buildPolicy($directive, $policy), + ]); + }, + ); + + $this->addPolicyTable( + t('Navigation'), + $this->translate( + 'Enable navigation items. Note: You will only be able to see your own navigation items,' + . ' and there is currently no way to see what others have configured for themselves.' + ), + 'csp_enable_navigation', + ! $disabledState, + function (CspReason $reason) { + return $reason instanceof NavigationCspReason; + }, + $csps, + [t('Type'), t('Parent'), t('Name'), t('Directive'), t('Value')], + function (NavigationCspReason $reason, string $directive, string $policy) { + $parent = $reason->item->getParent(); + if ($parent === null) { + $parentCell = Table::td(t('None'))->setAttribute('class', 'empty-state'); + } else { + $parentCell = Table::td($parent->getName()); + } + return Table::tr([ + Table::td($reason->type), + $parentCell, + Table::td($reason->item->getName()), + Table::td($directive), + $this->buildPolicy($directive, $policy), + ]); + }, + ); + +// $this->add(HtmlElement::create( +// 'div', +// [ +// 'class' => 'collapsible', +// 'data-visible-height' => 250, +// ], +// $table, +// )); + $this->addElement( 'checkbox', 'use_custom_csp', @@ -66,15 +269,13 @@ protected function assemble(): void 'description' => $this->translate( 'Specify whether to use a custom, user provided, string as the CSP-Header.', ), - 'class' => 'autosubmit', + 'class' => 'autosubmit csp-form-content-aligned csp-label-header-h3 csp-form-header', 'checkedValue' => '1', 'uncheckedValue' => '0', ], ); if ($this->isCustomCspEnabled()) { - $this->addElement('hidden', 'include_user_content'); - $this->addHtml((new Callout( CalloutType::Warning, $this->translate( @@ -84,70 +285,34 @@ protected function assemble(): void ), $this->translate('Warning: Use at your own risk!'), ))->setFormElement()); + } - $this->addElement('textarea', 'custom_csp', [ - 'label' => $this->translate('Custom CSP'), - 'description' => $this->translate( - 'Set a custom CSP-Header. This completely overrides the automatically generated one.' - . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.', - ), - 'validators' => [ - new CallbackValidator(function ($value, CallbackValidator $validator) { - if (empty($value)) { - return true; - } - - try { - $value = str_replace('{style_nonce}', "'nonce-validation'", $value); - CspInstance::fromString($value); - } catch (Exception $e) { - $validator->addMessage($e->getMessage()); - return false; - } - + $this->addElement('textarea', 'custom_csp', [ + 'label' => $this->translate(''), + 'description' => $this->translate( + 'Set a custom CSP-Header. This completely overrides the automatically generated one.' + . ' Use the placeholder {style_nonce} to insert the automatically generated style nonce.', + ), + 'rows' => static::TEXTAREA_ROWS, + 'disabled' => ! $this->isCustomCspEnabled(), + 'validators' => [ + new CallbackValidator(function ($value, CallbackValidator $validator) { + if (empty($value)) { return true; - }), - ] - ]); - } else { - $this->addElement('hidden', 'custom_csp'); + } - $this->addElement( - 'checkbox', - 'include_user_content', - [ - 'label' => $this->translate('Include User Content'), - 'description' => $this->translate( - 'If enabled, the user defined content like iframes in dashboards or ' - . 'menus will be included. Note: You will only be able to see the content that you ' - . 'have access to. There is no way to know what others have configured for themselves', - ), - 'class' => 'autosubmit', - 'checkedValue' => '1', - 'uncheckedValue' => '0', - ], - ); - - Csp::createNonce(); - $this->addElement('textarea', 'generated_csp', [ - 'label' => $this->translate('Generated CSP'), - 'description' => $this->translate( - 'This is the current CSP-Header. You can always safely go back to this by disabling the' - . ' Enable Custom CSP checkbox above.', - ), - 'disabled' => true, - 'value' => Csp::getAutomaticHeader($this->shouldIncludeUserContent())->getHeader(), - ]); + try { + $value = str_replace('{style_nonce}', "'nonce-validation'", $value); + CspInstance::fromString($value); + } catch (Exception $e) { + $validator->addMessage($e->getMessage()); + return false; + } - $this->add(HtmlElement::create( - 'div', - [ - 'class' => 'collapsible', - 'data-visible-height' => 250, - ], - new CspConfigurationTable($this->shouldIncludeUserContent()), - )); - } + return true; + }), + ] + ]); } $this->addElement('submit', 'submit', [ @@ -162,14 +327,10 @@ protected function onSuccess(): void $section = $config->getSection('security'); $beforeSection = clone $section; $section['use_strict_csp'] = $this->getValue('use_strict_csp'); - if ($this->isCspEnabled()) { - $section['use_custom_csp'] = $this->getValue('use_custom_csp'); - if ($this->isCustomCspEnabled()) { - $section['custom_csp'] = $this->getValue('custom_csp'); - } else { - $section['include_user_content'] = $this->getValue('include_user_content'); - } - } + $section['csp_enable_modules'] = $this->getValue('csp_enable_modules'); + $section['csp_enable_dashboards'] = $this->getValue('csp_enable_dashboards'); + $section['csp_enable_navigation'] = $this->getValue('csp_enable_navigation'); + $section['use_custom_csp'] = $this->getValue('use_custom_csp'); $this->changed = ! empty(array_diff_assoc( iterator_to_array($section), @@ -188,11 +349,6 @@ public function hasConfigChanged(): bool return $this->changed; } - public function shouldIncludeUserContent(): bool - { - return $this->getValue('include_user_content') === '1'; - } - public function isCspEnabled(): bool { return $this->getValue('use_strict_csp') === '1'; @@ -200,6 +356,217 @@ public function isCspEnabled(): bool public function isCustomCspEnabled(): bool { - return $this->getValue('use_custom_csp') === '1'; + return $this->getPopulatedValue('use_custom_csp') === '1'; + } + + /** + * @param string $title the title of the policy table + * @param string|null $description a short description of the section + * @param string|null $field the name of the checkbox to enable/disable the policy table + * @param bool $enabled is the section enabled? + * @param callable $filter a filter function to determine whether to include a policy in the table + * @param LoadedCsp[] $csps the loaded CSPs + * @param array $header the header of the table + * @param callable $rowBuilder a function to build a row of the table + * + * @return void + */ + protected function addPolicyTable( + string $title, + ?string $description, + ?string $field, + bool $enabled, + callable $filter, + array $csps, + array $header, + callable $rowBuilder, + ): void { + $disabledClass = $enabled ? '' : 'csp-disabled'; + + if ($field !== null) { + $this->addElement('checkbox', $field, [ + 'label' => sprintf($this->translate('Enable %s'), $title), + 'description' => $description, + 'class' => "autosubmit csp-form-content-aligned csp-label-header-h4 $disabledClass", + 'checkedValue' => '1', + 'uncheckedValue' => '0', + 'disabled' => ! $enabled, + ]); + + if ($disabledClass === '') { + $disabledClass = $this->getValue($field) ? '' : 'csp-disabled'; + } + } else { + $this->add(HtmlElement::create('h4', ['class' => "csp-form-hint $disabledClass"], $title)); + } + + $rows = []; + foreach ($csps as $csp) { + if (! $filter($csp->loadReason)) { + continue; + } + foreach ($csp->getDirectives() as $directive => $policies) { + foreach ($policies as $policy) { + $rows[] = $rowBuilder($csp->loadReason, $directive, $policy); + } + } + } + + if (count($rows) === 0) { + $this->add( + HtmlElement::create('p', ['class' => 'csp-form-hint'], sprintf('No %s policies found.', $title)) + ); + return; + } + + $table = new Table(); + $table->addAttributes(Attributes::create(['class' => ['csp-config-table', $disabledClass]])); + $headerRow = Table::tr(); + foreach ($header as $h) { + $headerRow->add(Table::th($h)); + } + $table->add($headerRow); + + foreach ($rows as $row) { + $table->add($row); + } + + $this->add($table); + } + + protected function getKeywordType(string $policy): ?string + { + if (in_array($policy, static::SECURE_KEYWORDS)) { + return 'secure'; + } + + if (in_array($policy, static::WARNING_KEYWORDS)) { + return 'warning'; + } + + return null; + } + + protected function getSchemeType(string $directive, string $policy): ?string + { + if (! str_ends_with($policy, ':')) { + return null; + } + + if (str_contains($policy, ' ')) { + return null; + } + + $schema = substr($policy, 0, -1); + + if (in_array($schema, static::SECURE_SCHEMAS)) { + return 'secure'; + } + + if (in_array($schema, static::WARNING_SCHEMAS)) { + return 'warning'; + } + + if ($schema === 'data' && in_array($directive, static::CRITICAL_DATA_DIRECTIVES)) { + return 'critical'; + } + + if ($schema === 'data' && in_array($directive, static::WARNING_DATA_DIRECTIVES)) { + return 'warning'; + } + + return 'unknown'; + } + + protected function isNonce(string $policy): bool + { + return (str_starts_with($policy, "'nonce-") && str_ends_with($policy, "'")); + } + + protected function buildPolicy(string $directive, string $policy): BaseHtmlElement + { + if ($policy === '*') { + $result = HtmlElement::create( + 'span', + ['class' => 'csp-wildcard'], + [ + $policy, + new Icon( + 'warning', + [ + 'class' => 'csp-policy-info', + 'title' => t( + 'This is a wildcard policy. It allows everything and should therefore be avoided.' + ), + ] + ), + ], + ); + } elseif (($keyword = $this->getKeywordType($policy)) !== null) { + $icon = match ($keyword) { + 'warning' => new Icon( + 'warning', + [ + 'class' => 'csp-policy-info', + 'title' => t('This is a potentially unsafe keyword.'), + ] + ), + default => null, + }; + $result = HtmlElement::create( + 'span', + ['class' => ['csp-keyword', 'csp-' . $keyword]], + [ + $policy, + $icon, + ] + ); + } elseif (($scheme = $this->getSchemeType($directive, $policy)) !== null) { + $icon = match ($scheme) { + 'warning' => new Icon( + 'warning', + [ + 'class' => 'csp-policy-info', + 'title' => t('This is a potentially unsafe scheme.'), + ] + ), + 'critical' => new Icon( + 'warning', + [ + 'class' => 'csp-policy-info', + 'title' => t('This is a critical scheme and should not be used.'), + ] + ), + default => null, + }; + $result = HtmlElement::create( + 'span', + ['class' => ['csp-scheme', 'csp-' . $scheme]], + [ + $policy, + $icon, + ] + ); + } elseif ($this->isNonce($policy)) { + $result = HtmlElement::create( + 'span', + ['class' => 'csp-nonce'], + [ + $policy, + new Icon( + 'info-circle', + [ + 'class' => 'csp-policy-info', + 'title' => t('This is an automatically generated nonce. Its value is unique per request.'), + ], + ), + ] + ); + } elseif (filter_var($policy, FILTER_VALIDATE_URL) !== false) { + $result = new Link($policy, $policy, ['target' => '_blank']); + } else { + $result = new Text($policy); + } + return Table::td($result, ['class' => 'csp-policies']); } } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index dc69f630f0..85ea3ece07 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -7,6 +7,7 @@ use Icinga\Application\Config; use Icinga\Application\Icinga; +use Icinga\Data\ConfigObject; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Loader\DashboardCspLoader; use Icinga\Security\Csp\Loader\ModuleCspLoader; @@ -61,8 +62,12 @@ public static function isEnabled(): bool /** * @return LoadedCsp[] */ - public static function load(?bool $includeUserContent = null): array + public static function load(?ConfigObject $config = null): array { + if ($config === null) { + $config = Config::app()->getSection('security'); + } + $nonce = static::getStyleNonce(); if (empty($nonce)) { throw new RuntimeException('No nonce set for CSS'); @@ -80,14 +85,15 @@ public static function load(?bool $includeUserContent = null): array ] ))->load()); - $result = array_merge($result, (new ModuleCspLoader())->load()); - - if ($includeUserContent === null) { - $includeUserContent = Config::app()->get('security', 'include_user_content', '0') === '1'; + if ($config->get('csp_enable_modules', '1')) { + $result = array_merge($result, (new ModuleCspLoader())->load()); } - if ($includeUserContent) { + if ($config->get('csp_enable_dashboards', '1')) { $result = array_merge($result, (new DashboardCspLoader())->load()); + } + + if ($config->get('csp_enable_navigation', '1')) { $result = array_merge($result, (new NavigationCspLoader())->load()); } @@ -140,9 +146,9 @@ protected static function getCustomHeader(): CspInstance * @return CspInstance Returns the generated header value. * @throws RuntimeException If no nonce set for CSS */ - public static function getAutomaticHeader(?bool $includeUserContent = null): CspInstance + public static function getAutomaticHeader(): CspInstance { - $csps = self::load($includeUserContent); + $csps = self::load(); return CspInstance::merge(...$csps); } diff --git a/library/Icinga/Web/Widget/CspConfigurationTable.php b/library/Icinga/Web/Widget/CspConfigurationTable.php deleted file mode 100644 index 72f74a265a..0000000000 --- a/library/Icinga/Web/Widget/CspConfigurationTable.php +++ /dev/null @@ -1,275 +0,0 @@ - 'csp-config-table']; - - /** @var string[] */ - protected const SECURE_KEYWORDS = [ - "'self'", - "'none'", - "'strict-dynamic'", - "'report-sample'", - "'report-sha256'", - "'report-sha384'", - "'report-sha512'", - ]; - - /** @var string[] */ - protected const WARNING_KEYWORDS = [ - "'unsafe-inline'", - "'unsafe-eval'", - "'unsafe-hashes'", - ]; - - /** @var string[] */ - protected const SECURE_SCHEMAS = [ - 'https', - 'wss', - ]; - - /** @var string[] */ - protected const WARNING_SCHEMAS = [ - 'http', - 'ws', - 'blob', - ]; - - /** @var string[] */ - protected const CRITICAL_DATA_DIRECTIVES = [ - 'default-src', - 'script-src', - 'object-src', - 'frame-src', - ]; - - /** @var string[] */ - protected const WARNING_DATA_DIRECTIVES = [ - 'style-src', - 'worker-src', - 'child-src', - 'base-uri', - ]; - - protected $tag = 'div'; - - public function __construct( - protected ?bool $includeUserContent = null, - ) { - } - - /** - * @param string $title - * @param callable $filter - * @param LoadedCsp[] $csps - * @param array $header - * @param callable $rowBuilder - * - * @return void - */ - protected function addPolicyTable( - string $title, - callable $filter, - array $csps, - array $header, - callable $rowBuilder, - ): void { - $rows = []; - foreach ($csps as $csp) { - if (! $filter($csp->loadReason)) { - continue; - } - foreach ($csp->getDirectives() as $directive => $policies) { - foreach ($policies as $policy) { - $rows[] = $rowBuilder($csp->loadReason, $directive, $policy); - } - } - } - - if (count($rows) === 0) { - return; - } - - $this->add(HtmlElement::create('h3', null, $title)); - - $table = new Table(); - $headerRow = Table::tr(); - foreach ($header as $h) { - $headerRow->add(Table::th($h)); - } - $table->add($headerRow); - - foreach ($rows as $row) { - $table->add($row); - } - - $this->add($table); - } - - protected function assemble(): void - { - $csps = Csp::load($this->includeUserContent); - - $this->addPolicyTable( - t('System'), - function (CspReason $reason) { - return $reason instanceof StaticCspReason - && $reason->name === 'system'; - }, - $csps, - [t('Directive'), t('Value')], - function (StaticCspReason $reason, string $directive, string $policy) { - return Table::tr([ - Table::td($directive), - $this->buildPolicy($directive, $policy), - ]); - }, - ); - - $this->addPolicyTable( - t('Dashboard'), - function (CspReason $reason) { - return $reason instanceof DashboardCspReason; - }, - $csps, - [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], - function (DashboardCspReason $reason, string $directive, string $policy) { - return Table::tr([ - Table::td($reason->pane->getName()), - Table::td($reason->dashlet->getName()), - Table::td($directive), - $this->buildPolicy($directive, $policy), - ]); - }, - ); - - // TODO: Handle other types of navigation in extra tables - $this->addPolicyTable( - t('Navigation'), - function (CspReason $reason) { - return $reason instanceof NavigationCspReason; - }, - $csps, - [t('Type'), t('Parent'), t('Name'), t('Directive'), t('Value')], - function (NavigationCspReason $reason, string $directive, string $policy) { - $parent = $reason->item->getParent(); - if ($parent === null) { - $parentCell = Table::td(t('None'))->setAttribute('class', 'empty-state'); - } else { - $parentCell = Table::td($parent->getName()); - } - return Table::tr([ - Table::td($reason->type), - $parentCell, - Table::td($reason->item->getName()), - Table::td($directive), - $this->buildPolicy($directive, $policy), - ]); - }, - ); - - $this->addPolicyTable( - t('Modules'), - function (CspReason $reason) { - return $reason instanceof ModuleCspReason; - }, - $csps, - [t('Module'), t('Directive'), t('Value')], - function (ModuleCspReason $reason, string $directive, string $policy) { - return Table::tr([ - Table::td($reason->module), - Table::td($directive), - $this->buildPolicy($directive, $policy), - ]); - }, - ); - } - - protected function getKeywordType(string $policy): ?string - { - if (in_array($policy, static::SECURE_KEYWORDS)) { - return 'secure'; - } - - if (in_array($policy, static::WARNING_KEYWORDS)) { - return 'warning'; - } - - return null; - } - - protected function getSchemeType(string $directive, string $policy): ?string - { - if (! str_ends_with($policy, ':')) { - return null; - } - - if (str_contains($policy, ' ')) { - return null; - } - - $schema = substr($policy, 0, -1); - - if (in_array($schema, static::SECURE_SCHEMAS)) { - return 'secure'; - } - - if (in_array($schema, static::WARNING_SCHEMAS)) { - return 'warning'; - } - - if ($schema === 'data' && in_array($directive, static::CRITICAL_DATA_DIRECTIVES)) { - return 'critical'; - } - - if ($schema === 'data' && in_array($directive, static::WARNING_DATA_DIRECTIVES)) { - return 'warning'; - } - - return 'unknown'; - } - - protected function isNonce(string $policy): bool - { - return (str_starts_with($policy, "'nonce-") && str_ends_with($policy, "'")); - } - - protected function buildPolicy(string $directive, string $policy): BaseHtmlElement - { - if ($policy === '*') { - $result = HtmlElement::create('span', ['class' => 'csp-wildcard'], $policy); - } elseif ($policy === "'self'") { - $result = HtmlElement::create('span', ['class' => 'csp-self'], $policy); - } elseif (($keyword = $this->getKeywordType($policy)) !== null) { - $result = HtmlElement::create('span', ['class' => ['csp-keyword', 'csp-' . $keyword]], $policy); - } elseif (($scheme = $this->getSchemeType($directive, $policy)) !== null) { - $result = HtmlElement::create('span', ['class' => ['csp-scheme', 'csp-' . $scheme]], $policy); - } elseif ($this->isNonce($policy)) { - $result = HtmlElement::create('span', ['class' => 'csp-nonce'], $policy); - } elseif (filter_var($policy, FILTER_VALIDATE_URL) !== false) { - $result = new Link($policy, $policy, ['target' => '_blank']); - } else { - $result = HtmlElement::create('span', null, $policy); - } - return Table::td($result, ['class' => 'csp-policies']); - } -} diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less index 228da7394c..b211a2f8fe 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -2,6 +2,9 @@ // Layout .csp-config-table { + overflow-x: auto; + display: block; + h3 { margin-top: 0; @@ -10,10 +13,8 @@ } } - table { - width: 100%; - overflow-x: auto; - display: block; + th { + min-width: 6em; } th:first-child, @@ -32,6 +33,11 @@ justify-content: end; gap: 0.25em; } + + .csp-policy-info { + margin-left: .5em; + opacity: .7; + } } // Style @@ -70,14 +76,6 @@ color: @color-critical; } - .csp-secure { - color: @color-ok; - } - - .csp-nonce { - color: @color-unknown; - } - a { font-weight: bold; @@ -93,9 +91,53 @@ .csp-config-table { padding-bottom: 2em; margin-left: 14em; + overflow-y: hidden; + } + + .csp-disabled, + .control-group:has(.csp-disabled) { + opacity: 0.5; + } + + p.csp-form-hint { + margin-left: 14em; + opacity: 0.5; + } + + h3.csp-form-hint { + margin-left: 12em; + } + + h4.csp-form-hint { + margin-left: 14em; + } + + .control-group:has(.csp-form-content-aligned) .control-label-group { + margin-left: 14em; + width: auto; + + label { + text-align: left; + } + } +} + +// Form style +.csp-config-form { + .control-group:has(.csp-label-header-h3, .csp-label-header-h4) { + margin: 0.556em 0 0 + } + + .control-group:has(.csp-label-header-h3) .control-label-group label { + font-size: 1.167em; + font-weight: bold; + } + + .control-group:has(.csp-label-header-h4) .control-label-group label { + font-weight: bold; } - .btn-primary { - margin-top: 1em; + .control-group:has(.csp-form-header) { + margin-top: 2em; } } From 9a0211e7745b38e651ab3c6ac3ff02cf354fbb67 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 24 Mar 2026 13:19:06 +0100 Subject: [PATCH 69/83] fixup! Store `custom_csp` --- application/forms/Config/Security/CspConfigForm.php | 1 + 1 file changed, 1 insertion(+) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 2644a973d5..605bf4e176 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -331,6 +331,7 @@ protected function onSuccess(): void $section['csp_enable_dashboards'] = $this->getValue('csp_enable_dashboards'); $section['csp_enable_navigation'] = $this->getValue('csp_enable_navigation'); $section['use_custom_csp'] = $this->getValue('use_custom_csp'); + $section['custom_csp'] = $this->getValue('custom_csp'); $this->changed = ! empty(array_diff_assoc( iterator_to_array($section), From 036fdd17dad712ecb4a784ea3e743d6c78294c82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alexander=20Rie=C3=9F?= Date: Tue, 24 Mar 2026 13:24:17 +0100 Subject: [PATCH 70/83] fixup! Remove large margin-bottom from table --- public/css/icinga/csp-config-editor.less | 1 - 1 file changed, 1 deletion(-) diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less index b211a2f8fe..6691ff746e 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -89,7 +89,6 @@ // Form layout .csp-config-form { .csp-config-table { - padding-bottom: 2em; margin-left: 14em; overflow-y: hidden; } From b9da9f0c7cf751576e07c251002c16ffa7923d06 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 71/83] Default use_custom_csp to 0 --- application/controllers/ConfigController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/application/controllers/ConfigController.php b/application/controllers/ConfigController.php index 400c429ccd..3ec545f2e6 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -135,7 +135,7 @@ public function securityAction(): void $cspForm = new CspConfigForm($config); $cspForm->populate([ 'use_strict_csp' => Csp::isEnabled(), - 'use_custom_csp' => $config->get('security', 'use_custom_csp'), + 'use_custom_csp' => $config->get('security', 'use_custom_csp', '0'), 'custom_csp' => $config->get('security', 'custom_csp'), 'csp_enable_modules' => $config->get('security', 'csp_enable_modules', '1'), 'csp_enable_dashboards' => $config->get('security', 'csp_enable_dashboards', '1'), From 440e8ecc5c741779f2846f0ea68c937bd9a6307b 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 72/83] Store security seection in config even if the section didn't exist before --- application/forms/Config/Security/CspConfigForm.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/application/forms/Config/Security/CspConfigForm.php b/application/forms/Config/Security/CspConfigForm.php index 605bf4e176..3a331e86f6 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -342,6 +342,8 @@ protected function onSuccess(): void return; } + $config->setSection('security', $section); + $config->saveIni(); } @@ -392,6 +394,7 @@ protected function addPolicyTable( 'checkedValue' => '1', 'uncheckedValue' => '0', 'disabled' => ! $enabled, + 'value' => $this->getPopulatedValue($field), ]); if ($disabledClass === '') { From 4b06ab83534f6f214b07c6c1d8e3567f7097dda6 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 73/83] 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 85ea3ece07..0d000b16fe 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; @@ -56,7 +58,7 @@ public static function addHeader(Response $response): void public static function isEnabled(): bool { - return Config::app()->get('security', 'use_strict_csp'); + return (bool) Config::app()->get('security', 'use_strict_csp', '0'); } /** @@ -85,16 +87,28 @@ public static function load(?ConfigObject $config = null): array ] ))->load()); - if ($config->get('csp_enable_modules', '1')) { - $result = array_merge($result, (new ModuleCspLoader())->load()); + try { + if ($config->get('csp_enable_modules', '1')) { + $result = array_merge($result, (new ModuleCspLoader())->load()); + } + } catch (Exception $e) { + Logger::warning('Module CSP loader failed: %s', $e->getMessage()); } - if ($config->get('csp_enable_dashboards', '1')) { - $result = array_merge($result, (new DashboardCspLoader())->load()); + try { + if ($config->get('csp_enable_dashboards', '1')) { + $result = array_merge($result, (new DashboardCspLoader())->load()); + } + } catch (Exception $e) { + Logger::warning('Dashboard CSP loader failed: %s', $e->getMessage()); } - if ($config->get('csp_enable_navigation', '1')) { - $result = array_merge($result, (new NavigationCspLoader())->load()); + try { + if ($config->get('csp_enable_navigation', '1')) { + $result = array_merge($result, (new NavigationCspLoader())->load()); + } + } catch (Exception $e) { + Logger::warning('Navigation CSP loader failed: %s', $e->getMessage()); } return $result; From a204e9b0af735f3c541c43db9d1e21c604bb4112 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 74/83] Return Csp instances instead of raw arrays --- .../Application/Hook/CspDirectiveHook.php | 12 +++++------- library/Icinga/Security/Csp/LoadedCsp.php | 7 +++++++ .../Security/Csp/Loader/ModuleCspLoader.php | 17 +++++++---------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspDirectiveHook.php index e592cdc86b..aa974b53da 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspDirectiveHook.php @@ -5,6 +5,7 @@ namespace Icinga\Application\Hook; use Icinga\Application\Hook; +use ipl\Web\Common\Csp; /** * Allow modules to provide custom CSP directives. @@ -13,15 +14,12 @@ abstract class CspDirectiveHook { /** - * Allow the module to provide custom directives for the CSP header. The return value should be an array - * with a directive as the key and the policies in an array as the value. The valid values can either be - * a concrete host, allowlisting subdomains for hosts or custom nonce for that module. + * Allow the module to provide custom directives for the CSP header. The return value should be an instance of Csp + * with the requested directives. * - * Example: [ 'img-src' => [ 'https://*.icinga.com', 'https://example.com/' ] ] - * - * @return array The CSP directives are the keys and the policies the values. + * @return Csp a CSP instance, this instance will be merged with all other requested directives. */ - abstract public function getCspDirectives(): array; + abstract public function getCspDirectives(): Csp; /** * Get all registered implementations diff --git a/library/Icinga/Security/Csp/LoadedCsp.php b/library/Icinga/Security/Csp/LoadedCsp.php index 5fdee5de4b..66b1b7d2ec 100644 --- a/library/Icinga/Security/Csp/LoadedCsp.php +++ b/library/Icinga/Security/Csp/LoadedCsp.php @@ -19,4 +19,11 @@ public function __construct( public readonly CspReason $loadReason, ) { } + + public static function fromCsp(Csp $csp, CspReason $reason): static + { + $instance = new static($reason); + $instance->directives = $csp->directives; + return $instance; + } } diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index 39c4f4e2c0..c05ed61464 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -28,18 +28,15 @@ public function load(): array $result = []; foreach (CspDirectiveHook::all() as $hook) { - $reason = new ModuleCspReason(ClassLoader::extractModuleName(get_class($hook))); - $csp = new LoadedCsp($reason); try { - foreach ($hook->getCspDirectives() as $directive => $policies) { - if (count($policies) === 0) { - continue; - } - - $csp->add($directive, $policies); - - $result[] = $csp; + $csp = $hook->getCspDirectives(); + if ($csp->isEmpty()) { + continue; } + $result[] = LoadedCsp::fromCsp( + $csp, + new ModuleCspReason(ClassLoader::extractModuleName(get_class($hook))), + ); } catch (Throwable $e) { Logger::error('Failed to CSP hook on request: %s', $e); } From 9401454f19bf55fd3af3ea5545d1c2f6d9d6c5cf 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 75/83] Change Hook name to CspPolicyProvider --- ...rectiveHook.php => CspPolicyProviderHook.php} | 16 ++++++++-------- .../Security/Csp/Loader/ModuleCspLoader.php | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) rename library/Icinga/Application/Hook/{CspDirectiveHook.php => CspPolicyProviderHook.php} (56%) diff --git a/library/Icinga/Application/Hook/CspDirectiveHook.php b/library/Icinga/Application/Hook/CspPolicyProviderHook.php similarity index 56% rename from library/Icinga/Application/Hook/CspDirectiveHook.php rename to library/Icinga/Application/Hook/CspPolicyProviderHook.php index aa974b53da..efd74ff44c 100644 --- a/library/Icinga/Application/Hook/CspDirectiveHook.php +++ b/library/Icinga/Application/Hook/CspPolicyProviderHook.php @@ -8,18 +8,18 @@ use ipl\Web\Common\Csp; /** - * Allow modules to provide custom CSP directives. + * Allow modules to provide custom Content-Security-Policy policies. * This hook is only used if the CSP header is enabled. */ -abstract class CspDirectiveHook +abstract class CspPolicyProviderHook { /** - * Allow the module to provide custom directives for the CSP header. The return value should be an instance of Csp - * with the requested directives. + * Allow the module to provide custom directives and policies for the CSP header. + * The return value should be an instance of Csp with the requested policies. * * @return Csp a CSP instance, this instance will be merged with all other requested directives. */ - abstract public function getCspDirectives(): Csp; + abstract public function getCsp(): Csp; /** * Get all registered implementations @@ -28,16 +28,16 @@ abstract public function getCspDirectives(): Csp; */ public static function all(): array { - return Hook::all('CspDirective'); + return Hook::all('CspPolicyProvider'); } /** - * Register the class as a CspDirectiveHook implementation + * Register the class as a CspPolicyProviderHook 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('CspPolicyProvider', static::class, static::class, true); } } diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index c05ed61464..48aac81be6 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -4,7 +4,7 @@ namespace Icinga\Security\Csp\Loader; use Icinga\Application\ClassLoader; -use Icinga\Application\Hook\CspDirectiveHook; +use Icinga\Application\Hook\CspPolicyProviderHook; use Icinga\Application\Logger; use Icinga\Security\Csp\LoadedCsp; use Icinga\Security\Csp\Reason\ModuleCspReason; @@ -12,14 +12,14 @@ /** * Loads CSP directives from modules. - * Modules can implement the {@see CspDirectiveHook} interface to provide custom CSP directives. + * Modules can implement the {@see CspPolicyProviderHook} 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 { /** * List all CSP directives from modules. - * See {@see CspDirectiveHook} for details. + * See {@see CspPolicyProviderHook} for details. * * @return LoadedCsp[] */ @@ -27,9 +27,9 @@ public function load(): array { $result = []; - foreach (CspDirectiveHook::all() as $hook) { + foreach (CspPolicyProviderHook::all() as $hook) { try { - $csp = $hook->getCspDirectives(); + $csp = $hook->getCsp(); if ($csp->isEmpty()) { continue; } From d34cf59496b1afe38c223ca1a9c01f4a38e47d5e 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 76/83] 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 3a331e86f6..cfa6c4bb8b 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -252,15 +252,6 @@ function (NavigationCspReason $reason, string $directive, string $policy) { }, ); -// $this->add(HtmlElement::create( -// 'div', -// [ -// 'class' => 'collapsible', -// 'data-visible-height' => 250, -// ], -// $table, -// )); - $this->addElement( 'checkbox', 'use_custom_csp', @@ -435,7 +426,14 @@ protected function addPolicyTable( $table->add($row); } - $this->add($table); + $this->add(HtmlElement::create( + 'div', + [ + 'class' => 'collapsible', + 'data-visible-height' => 100, + ], + $table, + )); } protected function getKeywordType(string $policy): ?string diff --git a/public/css/icinga/csp-config-editor.less b/public/css/icinga/csp-config-editor.less index 6691ff746e..8d0c983022 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -4,6 +4,7 @@ .csp-config-table { overflow-x: auto; display: block; + padding-bottom: 1em; h3 { margin-top: 0; @@ -124,7 +125,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 7ba3020c8e8e36414f8f29092b7505f1eae60196 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 77/83] 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 cfa6c4bb8b..d27a76513d 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -158,38 +158,40 @@ protected function assemble(): void ), )); - $this->addPolicyTable( - t('System'), - null, - null, - ! $disabledState, + $this->addPolicyTitleElement($this->translate('System'), 'unused', null, ! $disabledState); + $this->addPolicyContentElement( + $csps, + [t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof StaticCspReason && $reason->name === 'system'; }, - $csps, - [t('Directive'), t('Value')], function (StaticCspReason $reason, string $directive, string $policy) { return Table::tr([ Table::td($directive), $this->buildPolicy($directive, $policy), ]); }, + ! $disabledState, + $this->translate('No system policies defined.') ); - $this->addPolicyTable( - t('Modules'), + $this->addPolicyTitleElement( + $this->translate('Modules'), $this->translate( 'Should module defined csp directives be enabled?' . ' Note: Modules can define or change csp directives at any point.' ), 'csp_enable_modules', ! $disabledState, + ); + + $this->addPolicyContentElement( + $csps, + [t('Module'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof ModuleCspReason; }, - $csps, - [t('Module'), t('Directive'), t('Value')], function (ModuleCspReason $reason, string $directive, string $policy) { return Table::tr([ Table::td($reason->module), @@ -197,21 +199,26 @@ function (ModuleCspReason $reason, string $directive, string $policy) { $this->buildPolicy($directive, $policy), ]); }, + $disabledState === false && $this->getValue('csp_enable_modules') === '1', + $this->translate('No module policies defined.') ); - $this->addPolicyTable( - t('Dashboard'), + $this->addPolicyTitleElement( + $this->translate('Dashboard'), $this->translate( 'Enable user defined dashboards. Note: You will only be able to see your own dashboards,' . ' and there is currently no way to see what others have configured for themselves.' ), 'csp_enable_dashboards', ! $disabledState, + ); + + $this->addPolicyContentElement( + $csps, + [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof DashboardCspReason; }, - $csps, - [t('Dashboard'), t('Dashlet'), t('Directive'), t('Value')], function (DashboardCspReason $reason, string $directive, string $policy) { return Table::tr([ Table::td($reason->pane->getName()), @@ -220,21 +227,26 @@ function (DashboardCspReason $reason, string $directive, string $policy) { $this->buildPolicy($directive, $policy), ]); }, + $disabledState === false && $this->getValue('csp_enable_dashboards') === '1', + $this->translate('No dashboard policies found.'), ); - $this->addPolicyTable( - t('Navigation'), + $this->addPolicyTitleElement( + $this->translate('Navigation'), $this->translate( 'Enable navigation items. Note: You will only be able to see your own navigation items,' . ' and there is currently no way to see what others have configured for themselves.' ), 'csp_enable_navigation', ! $disabledState, + ); + + $this->addPolicyContentElement( + $csps, + [t('Navigation'), t('Parent'), t('Name'), t('Directive'), t('Value')], function (CspReason $reason) { return $reason instanceof NavigationCspReason; }, - $csps, - [t('Type'), t('Parent'), t('Name'), t('Directive'), t('Value')], function (NavigationCspReason $reason, string $directive, string $policy) { $parent = $reason->item->getParent(); if ($parent === null) { @@ -250,6 +262,8 @@ function (NavigationCspReason $reason, string $directive, string $policy) { $this->buildPolicy($directive, $policy), ]); }, + $disabledState === false && $this->getValue('csp_enable_navigation') === '1', + $this->translate('No navigation policies found.'), ); $this->addElement( @@ -354,47 +368,55 @@ public function isCustomCspEnabled(): bool } /** - * @param string $title the title of the policy table - * @param string|null $description a short description of the section - * @param string|null $field the name of the checkbox to enable/disable the policy table - * @param bool $enabled is the section enabled? - * @param callable $filter a filter function to determine whether to include a policy in the table - * @param LoadedCsp[] $csps the loaded CSPs - * @param array $header the header of the table - * @param callable $rowBuilder a function to build a row of the table + * @param string $title the title of the section + * @param string|null $description the description of the section + * @param string|null $field the name of the checkbox that controls the section + * @param bool $enabled whether the section should be enabled * * @return void */ - protected function addPolicyTable( + protected function addPolicyTitleElement( string $title, ?string $description, ?string $field, bool $enabled, - callable $filter, - array $csps, - array $header, - callable $rowBuilder, ): void { $disabledClass = $enabled ? '' : 'csp-disabled'; - if ($field !== null) { - $this->addElement('checkbox', $field, [ - 'label' => sprintf($this->translate('Enable %s'), $title), - 'description' => $description, - 'class' => "autosubmit csp-form-content-aligned csp-label-header-h4 $disabledClass", - 'checkedValue' => '1', - 'uncheckedValue' => '0', - 'disabled' => ! $enabled, - 'value' => $this->getPopulatedValue($field), - ]); - - if ($disabledClass === '') { - $disabledClass = $this->getValue($field) ? '' : 'csp-disabled'; - } - } else { + if ($field == null) { $this->add(HtmlElement::create('h4', ['class' => "csp-form-hint $disabledClass"], $title)); + return; } + $this->addElement('checkbox', $field, [ + 'label' => sprintf($this->translate('Enable %s'), $title), + 'description' => $description, + 'class' => "autosubmit csp-form-content-aligned csp-label-header-h4 $disabledClass", + 'checkedValue' => '1', + 'uncheckedValue' => '0', + 'disabled' => ! $enabled, + 'value' => $this->getPopulatedValue($field), + ]); + } + + /** + * @param LoadedCsp[] $csps the list of cps along with their reasons + * @param string[] $header the header of the table + * @param callable $filter a filter function that returns true if the csp should be included in the table + * @param callable $rowBuilder a function that builds a row for the table + * @param bool $enabled whether the content should be enabled + * @param string $emptyText the text to display if there are no policies + * + * @return void + */ + protected function addPolicyContentElement( + array $csps, + array $header, + callable $filter, + callable $rowBuilder, + bool $enabled, + string $emptyText, + ): void { $rows = []; foreach ($csps as $csp) { if (! $filter($csp->loadReason)) { @@ -409,13 +431,13 @@ protected function addPolicyTable( if (count($rows) === 0) { $this->add( - HtmlElement::create('p', ['class' => 'csp-form-hint'], sprintf('No %s policies found.', $title)) + HtmlElement::create('p', ['class' => 'csp-form-hint'], $emptyText) ); return; } $table = new Table(); - $table->addAttributes(Attributes::create(['class' => ['csp-config-table', $disabledClass]])); + $table->addAttributes(Attributes::create(['class' => ['csp-config-table', $enabled ? '' : 'csp-disabled']])); $headerRow = Table::tr(); foreach ($header as $h) { $headerRow->add(Table::th($h)); From 55334f28dcef60ce8144f542b49b69a5cf62ca30 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 78/83] 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 3ec545f2e6..2ce1bee1d0 100644 --- a/application/controllers/ConfigController.php +++ b/application/controllers/ConfigController.php @@ -129,14 +129,14 @@ public function securityAction(): void { $this->assertPermission('config/security'); - $this->view->title = $this->translate('General'); + $this->view->title = $this->translate('Security'); $config = Config::app(); $cspForm = new CspConfigForm($config); $cspForm->populate([ 'use_strict_csp' => Csp::isEnabled(), '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 d27a76513d..b025e17ff5 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -336,7 +336,7 @@ protected function onSuccess(): void $section['csp_enable_dashboards'] = $this->getValue('csp_enable_dashboards'); $section['csp_enable_navigation'] = $this->getValue('csp_enable_navigation'); $section['use_custom_csp'] = $this->getValue('use_custom_csp'); - $section['custom_csp'] = $this->getValue('custom_csp'); + $section['custom_csp'] = $this->getValue('custom_csp', ''); $this->changed = ! empty(array_diff_assoc( iterator_to_array($section), diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php index 686019c56f..54ee094f67 100644 --- a/library/Icinga/Security/Csp/Loader/CspLoader.php +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -6,15 +6,15 @@ use Icinga\Security\Csp\LoadedCsp; /** - * Base class for CSP loaders. + * Interface for CSP loaders. * A loader is responsible for loading CSP directives from a specific source. */ -abstract class CspLoader +interface CspLoader { /** * Load the CSP directives from the source. * * @return LoadedCsp[] */ - abstract public function load(): array; + public function load(): array; } diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index ef0e3dc7ab..8c710049ab 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -16,7 +16,7 @@ * If an external URL is found, it adds a CSP directive for the URL's host and port. * The CSP directive allows the iframe to be embedded on the page.' */ -class DashboardCspLoader extends CspLoader +class DashboardCspLoader implements CspLoader { /** * Fetches all dashlets for the current user that have an external URL. diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index 48aac81be6..03709c5e27 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -15,7 +15,7 @@ * Modules can implement the {@see CspPolicyProviderHook} interface to provide custom CSP directives. * The hook is called for each request, allowing modules to dynamically add or modify CSP policies. */ -class ModuleCspLoader extends CspLoader +class ModuleCspLoader implements CspLoader { /** * List all CSP directives from modules. diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index 81fb9c0862..cd6bfb53bc 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -15,7 +15,7 @@ * Loads CSP directives for navigation items that have an external URL. * The CSP directive allows the iframe to be embedded on the page. */ -class NavigationCspLoader extends CspLoader +class NavigationCspLoader implements CspLoader { /** * Fetches navigation items for the current user. diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php index 4370ba00c0..565d8c519f 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -10,7 +10,7 @@ * Loads CSP directives from a static array. * Useful for testing or providing a static CSP configuration. */ -class StaticCspLoader extends CspLoader +class StaticCspLoader implements CspLoader { /** * @param string $name the name to display for CSP reason diff --git a/library/Icinga/Security/Csp/Reason/CspReason.php b/library/Icinga/Security/Csp/Reason/CspReason.php index 0aa546843d..d56b64df9b 100644 --- a/library/Icinga/Security/Csp/Reason/CspReason.php +++ b/library/Icinga/Security/Csp/Reason/CspReason.php @@ -4,9 +4,9 @@ namespace Icinga\Security\Csp\Reason; /** - * Base class for CSP reasons. + * Base interface for CSP reasons. * Only used for type hinting. */ -class CspReason +interface CspReason { } diff --git a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php index 3d06328384..495f1f1c18 100644 --- a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php +++ b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php @@ -10,15 +10,15 @@ * Reason for loading a CSP directive for a dashboard dashlet. * The CSP directive allows the iframe to be embedded on the page. */ -class DashboardCspReason extends CspReason +readonly class DashboardCspReason implements CspReason { /** * @param Pane $pane the pane that contains the dashlet * @param Dashlet $dashlet the dashlet to load the CSP directive for */ public function __construct( - public readonly Pane $pane, - public readonly Dashlet $dashlet, + public Pane $pane, + public Dashlet $dashlet, ) { } } diff --git a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php index a5d4e82425..d6dc7acb85 100644 --- a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php +++ b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php @@ -7,13 +7,13 @@ * Reason for loading a CSP directive for a module. * The CSP directive allows the module to be loaded. */ -class ModuleCspReason extends CspReason +readonly class ModuleCspReason implements CspReason { /** * @param string $module the module to load the CSP directive for */ public function __construct( - public readonly string $module, + public string $module, ) { } } diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index f9c8e8c462..1e69fd53de 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -9,11 +9,11 @@ * Reason for loading a CSP directive for a navigation item. * The CSP directive allows the iframe to be embedded on the page. */ -class NavigationCspReason extends CspReason +readonly class NavigationCspReason implements CspReason { public function __construct( - public readonly string $type, - public readonly NavigationItem $item, + public string $type, + public NavigationItem $item, ) { } } diff --git a/library/Icinga/Security/Csp/Reason/StaticCspReason.php b/library/Icinga/Security/Csp/Reason/StaticCspReason.php index 0716d3a8d2..ac8715dc2d 100644 --- a/library/Icinga/Security/Csp/Reason/StaticCspReason.php +++ b/library/Icinga/Security/Csp/Reason/StaticCspReason.php @@ -7,13 +7,13 @@ * A hardcoded CSP reason. * Useful for testing or providing a static CSP configuration. */ -class StaticCspReason extends CspReason +readonly class StaticCspReason implements CspReason { /** * @param string $name the name to display for CSP reason */ public function __construct( - public readonly string $name, + public string $name, ) { } } diff --git a/library/Icinga/Util/Csp.php b/library/Icinga/Util/Csp.php index 0d000b16fe..ec79d90d48 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -52,8 +52,7 @@ private function __construct() */ public static function addHeader(Response $response): void { - $header = static::getHeader(); - $response->setHeader('Content-Security-Policy', $header, true); + $response->setHeader('Content-Security-Policy', static::getHeader(), true); } public static function isEnabled(): bool From 65a8a1940bab04070efdca385b7827e756d3f475 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 79/83] 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 8d0c983022..0141bebee6 100644 --- a/public/css/icinga/csp-config-editor.less +++ b/public/css/icinga/csp-config-editor.less @@ -94,6 +94,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 a3525b07fc52ff4baebc398f3a95400fe36b87a0 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 80/83] Return an empty array instead of throwing an error --- library/Icinga/Security/Csp/Loader/DashboardCspLoader.php | 3 +-- library/Icinga/Security/Csp/Loader/NavigationCspLoader.php | 3 +-- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index 8c710049ab..2e6047e8e3 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -8,7 +8,6 @@ use Icinga\Security\Csp\Reason\DashboardCspReason; use Icinga\Web\Url; use Icinga\Web\Widget\Dashboard; -use RuntimeException; /** * This loader is responsible for loading CSP directives for external URLs in dashboard panes. @@ -27,7 +26,7 @@ public function load(): array { $user = Auth::getInstance()->getUser(); if ($user === null) { - throw new RuntimeException('No user logged in'); + return []; } $dashboard = new Dashboard(); diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index cd6bfb53bc..56544b75af 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -9,7 +9,6 @@ use Icinga\Security\Csp\Reason\NavigationCspReason; use Icinga\Web\Navigation\Navigation; use Icinga\Web\Navigation\NavigationItem; -use RuntimeException; /** * Loads CSP directives for navigation items that have an external URL. @@ -31,7 +30,7 @@ public function load(): array $auth = Auth::getInstance(); if (! $auth->isAuthenticated()) { - throw new RuntimeException('No user logged in'); + return []; } $navigationType = Navigation::getItemTypeConfiguration(); From 7e2f24ce7a6d14b60f6e75f6b63e1246f0216cbd 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 81/83] Change license and use SPDX-Header --- application/forms/Config/Security/CspConfigForm.php | 3 ++- library/Icinga/Application/Hook/CspPolicyProviderHook.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 b025e17ff5..6cee3ed9fc 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/CspPolicyProviderHook.php b/library/Icinga/Application/Hook/CspPolicyProviderHook.php index efd74ff44c..9e9008c19d 100644 --- a/library/Icinga/Application/Hook/CspPolicyProviderHook.php +++ b/library/Icinga/Application/Hook/CspPolicyProviderHook.php @@ -1,6 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Application\Hook; diff --git a/library/Icinga/Security/Csp/LoadedCsp.php b/library/Icinga/Security/Csp/LoadedCsp.php index 66b1b7d2ec..c9ba26cc80 100644 --- a/library/Icinga/Security/Csp/LoadedCsp.php +++ b/library/Icinga/Security/Csp/LoadedCsp.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp; diff --git a/library/Icinga/Security/Csp/Loader/CspLoader.php b/library/Icinga/Security/Csp/Loader/CspLoader.php index 54ee094f67..6fe3f6bdbb 100644 --- a/library/Icinga/Security/Csp/Loader/CspLoader.php +++ b/library/Icinga/Security/Csp/Loader/CspLoader.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Loader; diff --git a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php index 2e6047e8e3..f7e8aa4fad 100644 --- a/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/DashboardCspLoader.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Loader; diff --git a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php index 03709c5e27..6ab0281455 100644 --- a/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/ModuleCspLoader.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Loader; diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index 56544b75af..14029aca6f 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Loader; diff --git a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php index 565d8c519f..6146ec4db3 100644 --- a/library/Icinga/Security/Csp/Loader/StaticCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/StaticCspLoader.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Loader; diff --git a/library/Icinga/Security/Csp/Reason/CspReason.php b/library/Icinga/Security/Csp/Reason/CspReason.php index d56b64df9b..ca9a9ff55f 100644 --- a/library/Icinga/Security/Csp/Reason/CspReason.php +++ b/library/Icinga/Security/Csp/Reason/CspReason.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Reason; diff --git a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php index 495f1f1c18..62ce62a8fa 100644 --- a/library/Icinga/Security/Csp/Reason/DashboardCspReason.php +++ b/library/Icinga/Security/Csp/Reason/DashboardCspReason.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Reason; diff --git a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php index d6dc7acb85..3e16b3ecb9 100644 --- a/library/Icinga/Security/Csp/Reason/ModuleCspReason.php +++ b/library/Icinga/Security/Csp/Reason/ModuleCspReason.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Reason; diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index 1e69fd53de..f279619527 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Reason; diff --git a/library/Icinga/Security/Csp/Reason/StaticCspReason.php b/library/Icinga/Security/Csp/Reason/StaticCspReason.php index ac8715dc2d..a85bd48bf6 100644 --- a/library/Icinga/Security/Csp/Reason/StaticCspReason.php +++ b/library/Icinga/Security/Csp/Reason/StaticCspReason.php @@ -1,5 +1,7 @@ +// SPDX-License-Identifier: GPL-3.0-or-later namespace Icinga\Security\Csp\Reason; From a7a64d6633882083b7253f1983021a9734a341ae 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 82/83] 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 6cee3ed9fc..057a5859a5 100644 --- a/application/forms/Config/Security/CspConfigForm.php +++ b/application/forms/Config/Security/CspConfigForm.php @@ -256,7 +256,7 @@ function (NavigationCspReason $reason, string $directive, string $policy) { $parentCell = Table::td($parent->getName()); } return Table::tr([ - Table::td($reason->type), + Table::td($reason->typeConfiguration['label'] ?? $reason->type), $parentCell, Table::td($reason->item->getName()), Table::td($directive), diff --git a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php index 14029aca6f..90cab3a0d6 100644 --- a/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php +++ b/library/Icinga/Security/Csp/Loader/NavigationCspLoader.php @@ -35,8 +35,8 @@ public function load(): array return []; } - $navigationType = Navigation::getItemTypeConfiguration(); - foreach ($navigationType as $type => $_) { + $navigationTypes = Navigation::getItemTypeConfiguration(); + foreach ($navigationTypes as $type => $typeConfig) { $navigation = new Navigation(); foreach ($navigation->load($type) as $rootItem) { foreach (self::yieldNavigation($rootItem) as $item) { @@ -46,7 +46,7 @@ public function load(): array $cspUrl .= ':' . $port; } - $csp = new LoadedCsp(new NavigationCspReason($type, $item)); + $csp = new LoadedCsp(new NavigationCspReason($type, $typeConfig, $item)); $csp->add('frame-src', $cspUrl); $result[] = $csp; } diff --git a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php index f279619527..5ac8576dc1 100644 --- a/library/Icinga/Security/Csp/Reason/NavigationCspReason.php +++ b/library/Icinga/Security/Csp/Reason/NavigationCspReason.php @@ -13,8 +13,14 @@ */ readonly class NavigationCspReason implements CspReason { + /** + * @param string $type the type of the navigation item + * @param array $typeConfiguration the configuration of the navigation item type + * @param NavigationItem $item the navigation item to load the CSP directive for + */ public function __construct( public string $type, + public array $typeConfiguration, public NavigationItem $item, ) { } From bfec8599a14b99a43d6c01cff51ec2f279069198 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 83/83] Write documentation --- doc/03-Configuration.md | 42 +++++++++++++++++++++++++++------------ doc/20-Advanced-Topics.md | 42 +++++++++++++++++++++++++++++---------- doc/60-Hooks.md | 31 +++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 23 deletions(-) diff --git a/doc/03-Configuration.md b/doc/03-Configuration.md index 89160bca0b..8e66a08c13 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 d3e0a60846..131e482950 100644 --- a/doc/20-Advanced-Topics.md +++ b/doc/20-Advanced-Topics.md @@ -121,24 +121,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 behaviour is +not desired, you can disable both by disabling the corresponding feature in the **Security page** at +**Configuration > Security** or by setting `csp_enable_navigation` or `csp_enable_dashboards` in the `config.ini`. +Note that you can only see navigation items and dashboards that you have access to. +You are still allowing everything any user has configured themselves. -[security] -use_strict_csp = "1" -``` +If it is necessary to add extra entries to the CSP header, you can do so by using the `CspPolicyProviderHook` hook, +read more about it [here](60-Hooks.md#csp-policy-provider). 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 `csp_header` 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. @@ -159,6 +170,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 @@ -357,7 +379,7 @@ which may help you already: If you are automating the installation of Icinga Web 2, you may want to skip the wizard and do things yourself. These are the steps you'd need to take assuming you are using MySQL/MariaDB. If you are using PostgreSQL please adapt -accordingly. Note you need to have successfully completed the Icinga 2 installation, installed the Icinga Web 2 packages +accordingly. Note you need to have successfully completed the Icinga 2 installation, installed the Icinga Web 2 packages, and all the other steps described above first. 1. Install PHP dependencies: `php`, `php-intl`, `php-imagick`, `php-gd`, `php-mysql`, `php-curl`, `php-mbstring` used diff --git a/doc/60-Hooks.md b/doc/60-Hooks.md index 2dc645d992..8b8aeaeea7 100644 --- a/doc/60-Hooks.md +++ b/doc/60-Hooks.md @@ -47,3 +47,34 @@ class ConfigFormEvents extends ConfigFormEventsHook } } ``` + +## CspPolicyProviderHook + +The `CspPolicyProviderHook` allows developers to add custom CSP policies to the Icinga Web 2 frontend. +It provides the method `getCsp()` which should return an array of CSP policies the module wants to add. +The policies are combined additively with the default policies, icingaweb2 generated ones and other module-defined +policies. +This hook is only called if `csp_enable_modules` is enabled in the Icinga Web 2 configuration. + +Hook example: + +```php +namespace Icinga\Module\Acme\ProvidedHook; + +use Icinga\Application\Hook\CspPolicyProviderHook; +use ipl\Web\Common\Csp; + +class CspPolicyProvider extends CspPolicyProviderHook +{ + public function getCsp(): Csp + { + $csp = new Csp(); + $csp->add('img-src', ['cdn.example.com', 'usercontent.example.com']); + $csp->add('style-src', 'cdn.example.com'); + + // ... + + return $csp; + } +} +```