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

namespace Icinga\Application\Hook;

abstract class CspDirectiveHook
{
/**
* Allow the module to provide custom directives for the CSP header. The return value should be an array
* with 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)
{
// 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;
}

$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;
}

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

}