Only open trusted iframe sources by default

Trusted in this case means, it was Icinga Web that
rendered a link and the user followed it. Whether
a source is trustworthy or not is detected by use
of the user's session id to hash it combined with
the source similar to how CSRF tokens are assembled.

(cherry picked from commit ec40efe1578c3c9cb445638f78e76a940a6864cf)
This commit is contained in:
Johannes Meyer 2025-02-21 16:18:16 +01:00
parent 6ddf61981c
commit 673998bb9a
5 changed files with 148 additions and 13 deletions

View File

@ -3,18 +3,108 @@
namespace Icinga\Controllers;
use Icinga\Web\Controller;
use Icinga\Web\Session;
use ipl\Html\BaseHtmlElement;
use ipl\Html\Html;
use ipl\Html\HtmlString;
use ipl\Html\Text;
use ipl\Web\Compat\CompatController;
use ipl\Web\Url;
use ipl\Web\Widget\Icon;
use ipl\Web\Widget\Tabs;
/**
* Display external or internal links within an iframe
*/
class IframeController extends Controller
class IframeController extends CompatController
{
/**
* Display iframe w/ the given URL
*/
public function indexAction()
public function indexAction(): void
{
$this->view->url = $this->params->getRequired('url');
$url = Url::fromPath($this->params->getRequired('url'));
$urlHash = $this->getRequest()->getHeader('X-Icinga-URLHash');
$expectedHash = hash('sha256', $url->getAbsoluteUrl() . Session::getSession()->getId());
$iframeUrl = Url::fromPath('iframe', ['url' => $url->getAbsoluteUrl()]);
if (! in_array($url->getScheme(), ['http', 'https'], true)) {
$this->httpBadRequest('Invalid URL scheme');
}
$this->injectTabs();
$this->getTabs()->setRefreshUrl($iframeUrl);
if ($urlHash) {
if ($urlHash !== $expectedHash) {
$this->httpBadRequest('Invalid URL hash');
}
} else {
$this->addContent(Html::tag('div', ['class' => 'iframe-warning'], [
Html::tag('h2', $this->translate('Attention!')),
Html::tag('p', ['class' => 'note'], $this->translate(
'You are about to open untrusted content embedded in Icinga Web! Only proceed,'
.' by clicking the link below, if you recognize and trust the source!'
)),
Html::tag('a', ['data-url-hash' => $expectedHash, 'href' => Html::escape($iframeUrl)], $url),
Html::tag('p', ['class' => 'reason'], [
new Icon('circle-info'),
Text::create($this->translate(
'You see this warning because you do not seem to have followed a link in Icinga Web.'
. ' You can bypass this in the future by configuring a navigation item instead.'
))
])
]));
return;
}
$this->getTabs()->setHash($expectedHash);
$this->addContent(Html::tag(
'div',
['class' => 'iframe-container'],
Html::tag('iframe', [
'src' => $url,
'sandbox' => 'allow-same-origin allow-scripts allow-popups allow-forms',
])
));
}
private function injectTabs(): void
{
$this->tabs = new class extends Tabs {
private $hash;
public function setHash($hash)
{
$this->hash = $hash;
return $this;
}
protected function assemble()
{
$tabHtml = substr($this->tabs->render(), 34, -5);
if ($this->refreshUrl !== null) {
$tabHtml = preg_replace(
[
'/(?<=class="refresh-container-control spinner" href=")([^"]*)/',
'/(\s)(?=href)/'
],
[
$this->refreshUrl->getAbsoluteUrl(),
' data-url-hash="' . $this->hash . '" '
],
$tabHtml
);
}
BaseHtmlElement::add(HtmlString::create($tabHtml));
}
};
$this->controls->setTabs($this->tabs);
}
}

View File

@ -1,8 +0,0 @@
<?php if (! $compact): ?>
<div class="controls">
<?= $tabs ?>
</div>
<?php endif ?>
<div class="iframe-container">
<iframe src="<?= $this->escape($url) ?>" frameborder="no"></iframe>
</div>

View File

@ -7,6 +7,7 @@ use Icinga\Application\Icinga;
use Icinga\Exception\ProgrammingError;
use Icinga\Util\StringHelper;
use Icinga\Web\Navigation\NavigationItem;
use Icinga\Web\Session;
use Icinga\Web\Url;
use Icinga\Web\View;
@ -188,6 +189,10 @@ class NavigationItemRenderer
$target = $item->getTarget();
if ($url->isExternal() && (!$target || in_array($target, $this->internalLinkTargets, true))) {
$item->setAttribute('data-url-hash', hash(
'sha256',
$url->getAbsoluteUrl() . Session::getSession()->getId()
));
$url = Url::fromPath('iframe', array('url' => $url));
}

View File

@ -282,6 +282,39 @@ a:hover > .icon-cancel {
// Responsive iFrames
.iframe-warning {
h2, p, a {
display: block;
width: fit-content;
font-size: 200%;
margin: 0 auto;
padding: 1em;
}
h2 {
font-size: 1000%;
color: @state-warning;
}
.note {
background: @gray-lighter;
}
a {
text-decoration: underline;
}
.reason {
.icon {
color: @text-color;
}
font-size: 100%;
background: @gray-lightest;
color: @text-color-light;
}
}
.iframe-container {
position: relative;
height: 0;
@ -295,6 +328,7 @@ a:hover > .icon-cancel {
top: 0;
height: 100%;
width: 100%;
border: none;
}
}

View File

@ -263,6 +263,7 @@
var $eventTarget = $(event.target);
var href = $a.attr('href');
var linkTarget = $a.attr('target');
const urlHash = this.dataset.urlHash;
var $target;
var formerUrl;
@ -373,7 +374,20 @@
}
// Load link URL
icinga.loader.loadUrl(href, $target);
if (urlHash) {
icinga.loader.loadUrl(
href,
$target,
undefined,
undefined,
undefined,
undefined,
undefined,
{ "X-Icinga-URLHash": urlHash }
);
} else {
icinga.loader.loadUrl(href, $target);
}
if ($a.closest('#menu').length > 0) {
// Menu links should remove all but the first layout column