diff --git a/css/setup.scss b/css/setup.scss index 327a5bc47..ee0e3b517 100644 --- a/css/setup.scss +++ b/css/setup.scss @@ -58,6 +58,9 @@ $progress-bar-error-bg-color: #F56565 !default; .center { text-align: center; } +.hidden { + display: none; +} /* Animations */ @keyframes progress_bar_color_ongoing { diff --git a/setup/csp-detection.js b/setup/csp-detection.js new file mode 100644 index 000000000..f10aaea0a --- /dev/null +++ b/setup/csp-detection.js @@ -0,0 +1,121 @@ +/** + * Detects the current server's Content-Security-Policy to stop the setup if any directive doesn't meet the application's requirements + * + * @type {{_FindItopVersionInURI: (function(): string|string), aFlags: {bUnsafeInlineScriptOk: boolean, bUnsafeEvalScriptOk: boolean, bUnsafeInlineStyleOk: boolean}, _TestUnsafeEvalScript: SetupCSPDetection._TestUnsafeEvalScript, _HideContinueButtonIfPolicyNotOk: SetupCSPDetection._HideContinueButtonIfPolicyNotOk, _TestUnsafeInlineStyle: SetupCSPDetection._TestUnsafeInlineStyle, Run: SetupCSPDetection.Run, _TestUnSafeInlineScript: SetupCSPDetection._TestUnSafeInlineScript, _AddErrorAlert: SetupCSPDetection._AddErrorAlert}} + * @since 2.7.11 3.0.5 3.1.2 3.2.0 N°7075 + */ +SetupCSPDetection = { + aFlags: { + bUnsafeInlineScriptOk: false, + bUnsafeEvalScriptOk: false, + bUnsafeInlineStyleOk: false, + }, + Run: function () { + this._TestUnSafeInlineScript(); + this._TestUnsafeEvalScript(); + this._TestUnsafeInlineStyle(); + this._HideContinueButtonIfPolicyNotOk(); + }, + /** + * Test if the CSP "unsafe-inline" directive for script-src if enabled, otherwise it forbids the setup to go further + * @private + */ + _TestUnSafeInlineScript: function() { + var sBaitElemID = "csp-detection--unsafe-inline-script-bait"; + + // Add inline script that should add an element in the DOM + var sAddedScript = ''; + $("body").append(sAddedScript); + + // Check if element has been added to the DOM + if ($("#" + sBaitElemID).length === 1) { + this.aFlags.bUnsafeInlineScriptOk = true; + } else { + this._AddErrorAlert("unsafe-inline", "script"); + } + }, + /** + * Test if the CSP "unsafe-eval" directive for script-src if enabled, otherwise it forbids the setup to go further + * @private + */ + _TestUnsafeEvalScript: function() { + var sBaitElemID = "csp-detection--unsafe-eval-script-bait"; + + // Add inline eval script that should add an element in the DOM + var sAddedScript = ''; + $("body").append(sAddedScript); + + // Check if element has been added to the DOM + if ($("#" + sBaitElemID).length === 1) { + this.aFlags.bUnsafeEvalScriptOk = true; + } else { + this._AddErrorAlert("unsafe-eval", "script"); + } + }, + /** + * Test if the CSP "unsafe-inline" directive for style-src if enabled, otherwise it forbids the setup to go further + * @private + */ + _TestUnsafeInlineStyle: function() { + var sBaitElemID = "csp-detection--unsafe-inline-style-bait"; + + // Add inline eval script that should add an element in the DOM + $("body").append("
If this is present in the DOM and visible, then unsafe-inline for styles policy must be allowed
"); + $("body").append(""); + + // Check if style has been applied + if ($("#" + sBaitElemID).is(":visible") === false) { + this.aFlags.bUnsafeInlineStyleOk = true; + } else { + // Remove bait div to avoid polluting the screen + $("#" + sBaitElemID).remove(); + this._AddErrorAlert("unsafe-inline", "style"); + } + }, + /** + * Hide continue button to prevent setup from going further if any policy is not OK + * @private + */ + _HideContinueButtonIfPolicyNotOk: function() { + if (false === this.aFlags.bUnsafeInlineScriptOk || false === this.aFlags.bUnsafeEvalScriptOk || false === this.aFlags.bUnsafeInlineStyleOk) { + // Hide next button to prevent user from going forward. + // Note that we don't remove it completely to be able to by-pass it. + $("#btn_next").addClass("ibo-is-hidden"); + } + }, + /** + * Internal helper to add an error alert in case of failure + * @param {String} sPolicyOption e.g. "unsafe-inline", "unsafe-eval", ... + * @param {String} sResourceType script|style + * @private + */ + _AddErrorAlert: function(sPolicyOption, sResourceType) { + var sFilesType = sResourceType === "script" ? "scripts" : "styles"; + + // Add alert in the DOM + $("
Error:Your server Content-Security-Policy header doesn't allow " + sPolicyOption + " " + sFilesType + ". Therefore, the application cannot be installed (see documentation).
") + .insertAfter( + $("#wiz_form h1:first") + ); + + }, + /** + * Internal helper to find the iTop version as wiki syntax from the script URI + * @returns {string|string} + * @private + */ + _FindItopVersionInURI: function() { + // First find script tag for the current file + var sScriptURI = $('script[src*="setup/csp-detection.js"]').attr("src"); + + // Extract parameter value from URI + var regex = new RegExp('[\\?&]' + 'itop_version_wiki_syntax' + '=([^&#]*)'); + var results = regex.exec(sScriptURI); + return results === null ? '' : decodeURIComponent(results[1].replace(/\+/g, ' ')); + } +}; + +$(document).ready(function() { + SetupCSPDetection.Run(); +}); diff --git a/setup/setuppage.class.inc.php b/setup/setuppage.class.inc.php index 1a145098a..35d10bf21 100644 --- a/setup/setuppage.class.inc.php +++ b/setup/setuppage.class.inc.php @@ -43,6 +43,7 @@ class SetupPage extends NiceWebPage $this->LinkScriptFromAppRoot('node_modules/@popperjs/core/dist/umd/popper.js'); $this->LinkScriptFromAppRoot('node_modules/tippy.js/dist/tippy-bundle.umd.js'); $this->LinkScriptFromAppRoot("setup/setup.js"); + $this->LinkScriptFromAppRoot("setup/csp-detection.js?itop_version_wiki_syntax=" . utils::GetItopVersionWikiSyntax()); $this->LinkStylesheetFromAppRoot('css/font-awesome/css/all.min.css'); $this->LinkStylesheetFromAppRoot('css/font-combodo/font-combodo.css'); $this->LinkStylesheetFromAppRoot('node_modules/tippy.js/dist/tippy.css'); diff --git a/sources/Application/WebPage/WebPage.php b/sources/Application/WebPage/WebPage.php index 424aa8264..b731e51d6 100644 --- a/sources/Application/WebPage/WebPage.php +++ b/sources/Application/WebPage/WebPage.php @@ -544,7 +544,9 @@ class WebPage implements Page return; } - if (false === utils::RealPath(APPROOT . $sFileRelPath, APPROOT)) { + // Ensure file is within the app folder + $sFileRelPathWithoutQueryParams = explode("?", $sFileRelPath)[0]; + if (false === utils::RealPath(APPROOT . $sFileRelPathWithoutQueryParams, APPROOT)) { IssueLog::Warning("Linked resource added to page with a path from outside app directory, it will be ignored.", LogChannels::CONSOLE, [ "linked_resource_uri" => $sFileRelPath, "request_uri" => $_SERVER['REQUEST_URI'] ?? '' /* CLI */, @@ -580,7 +582,9 @@ class WebPage implements Page return; } - $sFileAbsPath = MODULESROOT . $sFileRelPath; + // Ensure file is within the app folder + $sFileRelPathWithoutQueryParams = explode("?", $sFileRelPath)[0]; + $sFileAbsPath = MODULESROOT . $sFileRelPathWithoutQueryParams; // For modules only, we don't check real path if symlink as the file would not be in under MODULESROOT if (false === is_link($sFileAbsPath) && false === utils::RealPath($sFileAbsPath, MODULESROOT)) { IssueLog::Warning("Linked resource added to page with a path from outside current env. directory, it will be ignored.", LogChannels::CONSOLE, [ diff --git a/tests/php-unit-tests/unitary-tests/sources/Application/WebPage/WebPageTest.php b/tests/php-unit-tests/unitary-tests/sources/Application/WebPage/WebPageTest.php index ec802bcc6..82bc6f601 100644 --- a/tests/php-unit-tests/unitary-tests/sources/Application/WebPage/WebPageTest.php +++ b/tests/php-unit-tests/unitary-tests/sources/Application/WebPage/WebPageTest.php @@ -58,6 +58,11 @@ class WebPageTest extends ItopDataTestCase "js/utils.js", 1, ], + "LinkScriptFromAppRoot: Relative URI of existing file with query params should be completed / added" => [ + "LinkScriptFromAppRoot", + "js/utils.js?foo=bar", + 1, + ], "LinkScriptFromAppRoot: Relative URI of NON existing file should be ignored" => [ "LinkScriptFromAppRoot", "js/some-file.js", @@ -80,6 +85,11 @@ class WebPageTest extends ItopDataTestCase "itop-portal-base/portal/public/js/toolbox.js", 1, ], + "LinkScriptFromModule: Relative URI of existing file with query params should be completed / added" => [ + "LinkScriptFromModule", + "itop-portal-base/portal/public/js/toolbox.js?foo=bar", + 1, + ], "LinkScriptFromModule: Relative URI of NON existing file should be completed / added" => [ "LinkScriptFromModule", "some-module/asset/js/some-file.js", @@ -107,6 +117,11 @@ class WebPageTest extends ItopDataTestCase "https://external.server/file.js", 1, ], + "LinkScriptFromURI: Absolute URI with query params should be added" => [ + "LinkScriptFromURI", + "https://external.server/file.js?foo=bar", + 1, + ], ]; } @@ -148,6 +163,11 @@ class WebPageTest extends ItopDataTestCase "css/login.css", 1, ], + "LinkStylesheetFromAppRoot: Relative URI of existing file with query params should be completed / added" => [ + "LinkStylesheetFromAppRoot", + "css/login.css?foo=bar", + 1, + ], "LinkStylesheetFromAppRoot: Relative URI of NON existing file should be ignored" => [ "LinkStylesheetFromAppRoot", "css/some-file.css", @@ -170,6 +190,11 @@ class WebPageTest extends ItopDataTestCase "itop-portal-base/portal/public/css/portal.css", 1, ], + "LinkStylesheetFromModule: Relative URI of existing file with query params should be completed / added" => [ + "LinkStylesheetFromModule", + "itop-portal-base/portal/public/css/portal.css?foo=bar", + 1, + ], "LinkStylesheetFromModule: Relative URI of NON existing file should be completed / added" => [ "LinkStylesheetFromModule", "some-module/asset/js/some-file.js", @@ -197,6 +222,11 @@ class WebPageTest extends ItopDataTestCase "https://external.server/file.css", 1, ], + "LinkStylesheetFromURI: Absolute URI with query params should be added" => [ + "LinkStylesheetFromURI", + "https://external.server/file.css?foo=bar", + 1, + ], ]; } } \ No newline at end of file