diff --git a/application/controllers/NavigationController.php b/application/controllers/NavigationController.php index 305ae90421..31a8beb96e 100644 --- a/application/controllers/NavigationController.php +++ b/application/controllers/NavigationController.php @@ -444,4 +444,4 @@ public function dashboardAction() $this->view->navigation = $navigation; $this->view->title = $navigation->getLabel(); } -} +} \ No newline at end of file diff --git a/application/forms/Config/General/ApplicationConfigForm.php b/application/forms/Config/General/ApplicationConfigForm.php index b88ea5dfaf..ac2e507e70 100644 --- a/application/forms/Config/General/ApplicationConfigForm.php +++ b/application/forms/Config/General/ApplicationConfigForm.php @@ -4,8 +4,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 @@ -60,6 +62,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).' @@ -67,6 +70,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/Application/Web.php b/library/Icinga/Application/Web.php index 934af0745d..5b5a3499cb 100644 --- a/library/Icinga/Application/Web.php +++ b/library/Icinga/Application/Web.php @@ -176,7 +176,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 c7fbf9a4c9..591e6fe307 100644 --- a/library/Icinga/Util/Csp.php +++ b/library/Icinga/Util/Csp.php @@ -4,9 +4,19 @@ 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; use RuntimeException; +use Icinga\Web\Navigation\Navigation; +use Icinga\Web\Widget\Dashboard; use function ipl\Stdlib\get_php_type; @@ -44,6 +54,19 @@ private function __construct() * @throws RuntimeException If no nonce set for CSS */ public static function addHeader(Response $response): void + { + $header = static::getContentSecurityPolicy(); + $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 { $csp = static::getInstance(); @@ -51,13 +74,77 @@ public static function addHeader(Response $response): void 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 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"; + } + + $cspDirectives['frame-src'][] = $policy; + } + // Allow modules to add their own csp directives in a limited fashion. + /** @var CspDirectiveHook $hook */ + foreach (Hook::all('CspDirective') as $hook) { + foreach ($hook->getCspDirectives() as $directive => $policies) { + // policy names contain only lowercase letters and '-'. Reject anything else. + if (!preg_match('|^[a-z\-]+$|', $directive)) { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Invalid CSP directive found: $directive"); + continue; + } + + // The default-src can only ever be 'self'. Disallow any updates to it. + if ($directive === "default-src") { + $errorSource = get_class($hook); + Logger::debug("$errorSource: Changing default-src is forbidden."); + continue; + } + + $cspDirectives[$directive] = $cspDirectives[$directive] ?? []; + foreach ($policies as $policy) { + $cspDirectives[$directive][] = $policy; + } + } + } + + $header = "default-src 'self'; "; + foreach ($cspDirectives as $directive => $policies) { + if (!empty($policies)) { + $header .= ' ' . implode(' ', array_merge([$directive, "'self'"], array_unique($policies))) . ';'; + } + } + + return $header; + } /** * Set/recreate nonce for dynamic CSS * @@ -67,9 +154,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); + } } /** @@ -79,7 +167,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; } /** @@ -108,4 +199,104 @@ 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() + { + $user = Auth::getInstance()->getUser(); + $menuItems = []; + if ($user === null) { + return $menuItems; + } + $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')]; + } + } + $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')]; + } + } + } + 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() + { + $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; + } + + if ($url->isExternal()) { + $absoluteUrl = $url->getAbsoluteUrl(); + if (filter_var($absoluteUrl, FILTER_VALIDATE_URL) !== false) { + $dashlets[] = [ + "name" => $dashlet->getName(), + "url" => $absoluteUrl + ]; + } + } + } + } + return $dashlets; + } + }