Skip to content
Draft
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
4 changes: 4 additions & 0 deletions dependency_injection/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@
'Twig\Components\\',
$projectDir . '/src/Twig/Components'
);
$services->load(
'Glpi\Security\ContentSecurityPolicy\\',
$projectDir . '/src/Glpi/Security/ContentSecurityPolicy'
);
$services->set(ReAuthManager::class)
->factory([ReAuthManager::class, 'getInstance']);

Expand Down
48 changes: 48 additions & 0 deletions js/common.js
Original file line number Diff line number Diff line change
Expand Up @@ -2332,3 +2332,51 @@ if (typeof module !== 'undefined' && module.exports) {
tableToDetails,
};
}

/**
* Add `X-Glpi-Csp-Nonce` to ajax requests (both XMLHttpRequest, which jQuery relies on, and native fetch).
*/
(function () {
const nonce = CFG_GLPI.csp_nonce;
if (!nonce || window.glpi_csp_nonce_patched) {
return;
}

const header_name = 'X-Glpi-Csp-Nonce';

const isSameOrigin = (url) => {
try {
return new URL(String(url), window.location.href).origin === window.location.origin;
} catch {
// Invalid URL, let the native function handle the error.
return false;
}
};

// `XMLHttpRequest` requests, including jQuery ones: `$.ajax()`, `$.get()`,
// `$.post()`, `$(...).load()`, ...
const native_xhr_open = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...args) {
native_xhr_open.call(this, method, url, ...args);
if (isSameOrigin(url)) {
// Never leak the nonce to another origin.
this.setRequestHeader(header_name, nonce);
}
};

// `fetch()` requests.
const native_fetch = window.fetch;
window.fetch = function (resource, options) {
const url = resource instanceof Request ? resource.url : resource;
if (!isSameOrigin(url)) {
// Never leak the nonce to another origin.
return native_fetch.call(this, resource, options);
}

const headers = new Headers(options?.headers ?? (resource instanceof Request ? resource.headers : undefined));
headers.set(header_name, nonce);
return native_fetch.call(this, resource, { ...options, headers: headers });
};

window.glpi_csp_nonce_patched = true;
})();
2 changes: 1 addition & 1 deletion js/modules/Forms/FieldDestinationAssociatedItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ export class GlpiFormFieldDestinationAssociatedItem {
script.text = script.text.replace(/rand:[0-9]+/g, `rand:'${id}'`);

// Execute the script
$.globalEval(script.text);
$.globalEval(script.text, { nonce: CFG_GLPI.csp_nonce });
});
}
}
2 changes: 1 addition & 1 deletion js/modules/Forms/FieldDestinationMultipleConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -205,7 +205,7 @@ export class GlpiFormFieldDestinationMultipleConfig {
script.text = script.text.replace(/rand:[0-9]+/g, `rand:'${id}'`);

// Execute the script
$.globalEval(script.text);
$.globalEval(script.text, { nonce: CFG_GLPI.csp_nonce });
});
});
}
Expand Down
2 changes: 1 addition & 1 deletion js/modules/Forms/QuestionItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,7 @@ export class GlpiFormQuestionTypeItem {
script.text = script.text.replace(/rand:[0-9]+/g, `rand:'${id}'`);

// Execute the script
$.globalEval(script.text);
$.globalEval(script.text, { nonce: CFG_GLPI.csp_nonce });
});
}
}
5 changes: 5 additions & 0 deletions js/src/vue/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ const component_context = import.meta.webpackContext('.', {
// eslint-disable-next-line no-global-assign
__webpack_public_path__ = `${CFG_GLPI.root_doc + __webpack_public_path__}/`;

// Lazy loaded chunks are injected as script tags and must carry the CSP nonce.
/* global __webpack_nonce__ */
// eslint-disable-next-line no-global-assign
__webpack_nonce__ = CFG_GLPI.csp_nonce;

const components = {};
component_context.keys().forEach((f) => {
// Ex: ./Debug/Toolbar.vue => DebugToolbar
Expand Down
6 changes: 6 additions & 0 deletions lib/bundles/monaco.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,10 @@ import * as monaco from 'monaco-editor/editor';
// eslint-disable-next-line no-global-assign
__webpack_public_path__ = `${CFG_GLPI.root_doc}/lib/`;

// Needed for CSP to work with the monaco worker.
self.MonacoEnvironment.getWorker = (module_id, label) => new Worker(
self.MonacoEnvironment.getWorkerUrl(module_id, label),
{ name: label },
);

window.monaco = monaco;
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?php

/**
* ---------------------------------------------------------------------
*
* GLPI - Gestionnaire Libre de Parc Informatique
*
* http://glpi-project.org
*
* @copyright 2015-2026 Teclib' and contributors.
* @licence https://www.gnu.org/licenses/gpl-3.0.html
*
* ---------------------------------------------------------------------
*
* LICENSE
*
* This file is part of GLPI.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* ---------------------------------------------------------------------
*/

namespace Glpi\Application\View\Extension;

use Glpi\Application\View\NodeVisitor\CspNonceNodeVisitor;
use Glpi\Security\ContentSecurityPolicy\NonceContext;
use Override;
use Twig\Extension\AbstractExtension;

/**
* Kept separate from other extensions: Twig's compiled template cache key
* includes the registered extension class names, so adding this extension
* forces templates compiled without the nonce visitor to be recompiled.
*/
final class ContentSecurityPolicyExtension extends AbstractExtension
{
public function __construct(private NonceContext $nonce_context) {}

public function getNonce(): string
{
return $this->nonce_context->getNonce();
}

#[Override]
public function getNodeVisitors(): array
{
return [
new CspNonceNodeVisitor(),
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
use Glpi\Application\View\ScriptType;
use Glpi\Config\ConfigContainer;
use Glpi\Locale\LanguageRegistry;
use Glpi\Security\ContentSecurityPolicy\NonceContext;
use Glpi\Toolbox\FrontEnd;
use Glpi\UI\Theme;
use Glpi\UI\ThemeManager;
Expand Down Expand Up @@ -339,7 +340,12 @@ public function configJs(): string
\array_map(fn(string $plugin_key) => "/plugins/{$plugin_key}", Plugin::getPlugins())
);

$nonce = NonceContext::getServiceInstance()->getNonce();
$script = sprintf('window.CFG_GLPI = %s;', json_encode($cfg_glpi, JSON_PRETTY_PRINT))
. "\n"
// Set the nonce into the general config so it can be reused by ajax
// requests and webpack chunks.
. \sprintf('window.CFG_GLPI.csp_nonce = %s;', json_encode($nonce))
. "\n"
. sprintf('window.GLPI_PLUGINS_PATH = %s;', json_encode($plugins_path, JSON_PRETTY_PRINT));

Expand Down
60 changes: 60 additions & 0 deletions src/Glpi/Application/View/Node/CspNonceNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

/**
* ---------------------------------------------------------------------
*
* GLPI - Gestionnaire Libre de Parc Informatique
*
* http://glpi-project.org
*
* @copyright 2015-2026 Teclib' and contributors.
* @licence https://www.gnu.org/licenses/gpl-3.0.html
*
* ---------------------------------------------------------------------
*
* LICENSE
*
* This file is part of GLPI.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* ---------------------------------------------------------------------
*/

namespace Glpi\Application\View\Node;

use Glpi\Application\View\Extension\ContentSecurityPolicyExtension;
use Twig\Attribute\YieldReady;
use Twig\Compiler;
use Twig\Node\Node;

/**
* Outputs the Content-Security-Policy nonce of the current response.
*/
#[YieldReady]
final class CspNonceNode extends Node
{
public function __construct(int $lineno)
{
parent::__construct([], [], $lineno);
}

public function compile(Compiler $compiler): void
{
$compiler
->addDebugInfo($this)
->write("yield \\htmlescape(\$this->env->getExtension(\\" . ContentSecurityPolicyExtension::class . "::class)->getNonce());\n")
;
}
}
130 changes: 130 additions & 0 deletions src/Glpi/Application/View/NodeVisitor/CspNonceNodeVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

/**
* ---------------------------------------------------------------------
*
* GLPI - Gestionnaire Libre de Parc Informatique
*
* http://glpi-project.org
*
* @copyright 2015-2026 Teclib' and contributors.
* @licence https://www.gnu.org/licenses/gpl-3.0.html
*
* ---------------------------------------------------------------------
*
* LICENSE
*
* This file is part of GLPI.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*
* ---------------------------------------------------------------------
*/

namespace Glpi\Application\View\NodeVisitor;

use Glpi\Application\View\Node\CspNonceNode;
use Twig\Environment;
use Twig\Node\CaptureNode;
use Twig\Node\Expression\ConstantExpression;
use Twig\Node\Node;
use Twig\Node\Nodes;
use Twig\Node\SetNode;
use Twig\Node\TextNode;
use Twig\NodeVisitor\NodeVisitorInterface;

use function Safe\preg_match;
use function Safe\preg_split;

/**
* Adds the Content-Security-Policy nonce to every `<script>` tags.
*
* Known limitation:
* - Will not handle dynamic tag like {{ '<script>'|raw }} or
* <{{ header_tag.tag }}>, which is acceptable.
*/
final class CspNonceNodeVisitor implements NodeVisitorInterface
{
/**
* Matches the `<script` opening tag (case insensitive), and captures the text that follows it.
*/
private const string SCRIPT_TAG_PATTERN = '/(<script)(?=[\s>\/])/i';

/**
* Matches the remaining of a tag that already defines a `nonce` attribute.
*/
private const string NONCE_ATTRIBUTE_PATTERN = '/^[^>]*\snonce\s*=/i';

public function enterNode(Node $node, Environment $env): Node
{
// `{% set var %}<script>...</script>{% endset %}` blocks that only
// contain text are converted to a constant value by the parser.
// Restore the capture to be able to add the nonce to their text.
if (
// Node is a {% set %} tag
$node instanceof SetNode
// Node is a block {% set x %}...{% endset %}, not {% set x = ... %}
&& $node->getAttribute('safe')
// The body was optimized into a constant (= it contains no dynamic Twig code)
&& !$node->getAttribute('capture')
&& $node->getNode('values') instanceof ConstantExpression
// Read value and confirm it is a string
&& \is_string($value = $node->getNode('values')->getAttribute('value'))
// Contains a script tag
&& preg_match(self::SCRIPT_TAG_PATTERN, $value) === 1
) {
$line = $node->getNode('values')->getTemplateLine();
$node->setNode('values', new CaptureNode(new TextNode($value, $line), $line));
$node->setAttribute('capture', true);
}

return $node;
}

public function leaveNode(Node $node, Environment $env): Node
{
if (!$node instanceof TextNode) {
return $node;
}

$parts = preg_split(self::SCRIPT_TAG_PATTERN, $node->getAttribute('data'), -1, PREG_SPLIT_DELIM_CAPTURE);
if (\count($parts) === 1) {
return $node;
}

// `$parts` contains the text before the first tag, followed by each tag
// and the text that follows it.
$line = $node->getTemplateLine();
$text = \array_shift($parts);
$nodes = [];
foreach (\array_chunk($parts, 2) as [$tag, $following_text]) {
if (preg_match(self::NONCE_ATTRIBUTE_PATTERN, $following_text) === 1) {
// The nonce has been added manually.
$text .= $tag . $following_text;
continue;
}
$nodes[] = new TextNode($text . $tag . ' nonce="', $line);
$nodes[] = new CspNonceNode($line);
$text = '"' . $following_text;
}
$nodes[] = new TextNode($text, $line);

return \count($nodes) === 1 ? $nodes[0] : new Nodes($nodes, $line);
}

public function getPriority(): int
{
return 0;
}
}
Loading
Loading