Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion application/controllers/NavigationController.php
Original file line number Diff line number Diff line change
Expand Up @@ -444,4 +444,4 @@ public function dashboardAction()
$this->view->navigation = $navigation;
$this->view->title = $navigation->getLabel();
}
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please restore the empty line at the end of the file as well.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Changes to NavigationControllerhave been reverted in #5477

9 changes: 9 additions & 0 deletions application/forms/Config/General/ApplicationConfigForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -60,13 +62,20 @@ 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).'
)
]
);

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',
Expand Down
18 changes: 18 additions & 0 deletions library/Icinga/Application/Hook/CspDirectiveHook.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?php
/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */
// SPDX-FileCopyrightText: 2026 Icinga GmbH <https://icinga.com>
// SPDX-License-Identifier: GPL-3.0-or-later

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither I'd add GPLv3 files in a (yet) GPLv2 project (our corporate lawyer has left the chat 🙈), nor I'd mix license header formats in one and the same project. Latter especially because the GPLv3 PR (which doesn't even exist yet, by the way!) will likely use xargs to apply the same command to all files. The more files have the same header format, the better.


namespace Icinga\Application\Hook;

abstract class CspDirectiveHook
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this is a new hook, add the new methods just like in #5433:

  • all()
  • register()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented in b939340 (#5477)

{
/**
* 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.
*
* Example: [ 'img-src' => [ 'https://*.media.tumblr.com', 'https://http.cat/' ] ]
*
* @return array<string, string[]> The CSP directives are the keys and the policies the values.
*/
abstract public function getCspDirectives(): array;
}
2 changes: 1 addition & 1 deletion library/Icinga/Application/Web.php
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ public function getViewRenderer()
return $this->viewRenderer;
}

private function hasAccessToSharedNavigationItem(&$config, Config $navConfig)
public function hasAccessToSharedNavigationItem(&$config, Config $navConfig)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that's needed if you use my proposal.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Reverted it in #5477

{
// TODO: Provide a more sophisticated solution

Expand Down
211 changes: 201 additions & 10 deletions library/Icinga/Util/Csp.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -44,20 +54,97 @@ 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();

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 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;
}
Comment on lines +91 to +105
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The validation is already performed at least partially in the other methods. Please make sure to do this only once. Preferably during the fetch logic, as in my proposal.


$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
*
Expand All @@ -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);
}
}

/**
Expand All @@ -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;
}

/**
Expand Down Expand Up @@ -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;
}
Comment on lines +229 to +252
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was rather thinking about a solution like this: (Untested)

I didn't ensure this fits with your remaining code, so adjust it accordingly as you see fit.

Suggested change
$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;
}
if (! Auth::isAuthenticated()) {
return [];
}
$origins = [];
foreach (Navigation::load($type) as $navItem) {
foreach (self::yieldNavigation($navItem) as $name => $url) {
$origins[] = $url->getScheme() . '://' . $url->getHost();
}
}
return $origins;
}
protected static function yieldNavigation(NavigationItem $item): Generator
{
if ($item->hasChildren()) {
foreach ($item as $child) {
yield from self::yieldNavigation($child);
}
} else {
if ($item->getTarget() !== '_blank' && $item->getUrl()->isExternal()) {
yield $item->getName() => $item->getUrl();
}
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Implemented your suggestion in #5477


/**
* 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;
}

}