Commit a77817e4 authored by Oliver Hader's avatar Oliver Hader Committed by Oliver Hader
Browse files

[TASK] Reduce directly invoked inline JavaScript

To decrease the amount of inline JavaScript that has been
generated by some PHP process assignments and invocations
are declared in a more strict way.

This would allow to make use of strict content security policy
denying invocation of unsafe-inline scripts.

Resolves: #91786
Releases: master
Change-Id: I89384d661ebd35a5fda10f9587a7f41db4f587aa
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/64123

Tested-by: core-ci's avatarcore-ci <typo3@b13.com>
Tested-by: Torben Hansen's avatarTorben Hansen <derhansen@gmail.com>
Tested-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Tested-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
Reviewed-by: Torben Hansen's avatarTorben Hansen <derhansen@gmail.com>
Reviewed-by: Benni Mack's avatarBenni Mack <benni@typo3.org>
Reviewed-by: Oliver Hader's avatarOliver Hader <oliver.hader@typo3.org>
parent 48d783b2
......@@ -119,6 +119,7 @@ export class AjaxDispatcher {
if (typeof json.requireJsModules === 'object') {
for (let requireJsModule of json.requireJsModules) {
// @todo https://forge.typo3.org/issues/95874
new Function(requireJsModule)();
}
}
......
......@@ -31,6 +31,7 @@ use TYPO3\CMS\Core\Http\JsonResponse;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Information\Typo3Version;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Type\File\ImageInfo;
use TYPO3\CMS\Core\Utility\GeneralUtility;
......@@ -85,6 +86,7 @@ class BackendController
BackendModuleRepository $backendModuleRepository,
ModuleTemplateFactory $moduleTemplateFactory
) {
$javaScriptRenderer = $pageRenderer->getJavaScriptRenderer();
$this->getLanguageService()->includeLLFile('EXT:core/Resources/Private/Language/locallang_misc.xlf');
$this->backendModuleRepository = $backendModuleRepository;
$this->iconFactory = $iconFactory;
......@@ -98,15 +100,22 @@ class BackendController
// Add default BE javascript
$this->pageRenderer->addJsFile('EXT:backend/Resources/Public/JavaScript/backend.js');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/LoginRefresh', 'function(LoginRefresh) {
LoginRefresh.initialize(' . GeneralUtility::jsonEncodeForJavaScript([
'intervalTime' => MathUtility::forceIntegerInRange((int)$GLOBALS['TYPO3_CONF_VARS']['BE']['sessionTimeout'] - 60, 60),
'loginFramesetUrl' => (string)$this->uriBuilder->buildUriFromRoute('login_frameset'),
'logoutUrl' => (string)$this->uriBuilder->buildUriFromRoute('logout'),
]) . ');
}');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/BroadcastService', 'function(service) { service.listen(); }');
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::forRequireJS('TYPO3/CMS/Backend/LoginRefresh')
->invoke('initialize', [
'intervalTime' => MathUtility::forceIntegerInRange((int)$GLOBALS['TYPO3_CONF_VARS']['BE']['sessionTimeout'] - 60, 60),
'loginFramesetUrl' => (string)$this->uriBuilder->buildUriFromRoute('login_frameset'),
'logoutUrl' => (string)$this->uriBuilder->buildUriFromRoute('logout'),
])
);
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::forRequireJS('TYPO3/CMS/Backend/BroadcastService')->invoke('listen')
);
// load the storage API and fill the UC into the PersistentStorage, so no additional AJAX call is needed
$javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::forRequireJS('TYPO3/CMS/Backend/Storage/Persistent')
->invoke('load', $this->getBackendUser()->uc)
);
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Module/Router');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/ModuleMenu');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Storage/ModuleStateStorage');
......@@ -115,12 +124,6 @@ class BackendController
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Modal');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/InfoWindow');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Viewport/ResizableNavigation');
// load the storage API and fill the UC into the PersistentStorage, so no additional AJAX call is needed
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/Storage/Persistent', 'function(PersistentStorage) {
PersistentStorage.load(' . json_encode($this->getBackendUser()->uc) . ');
}');
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/DebugConsole');
$this->pageRenderer->addInlineLanguageLabelFile('EXT:core/Resources/Private/Language/locallang_core.xlf');
......@@ -348,11 +351,8 @@ class BackendController
'username' => htmlspecialchars($beUser->user['username']),
'showRefreshLoginPopup' => (bool)($GLOBALS['TYPO3_CONF_VARS']['BE']['showRefreshLoginPopup'] ?? false),
];
$this->pageRenderer->addJsInlineCode(
'BackendConfiguration',
'TYPO3.configuration = ' . json_encode($t3Configuration) . ';',
false
$this->pageRenderer->getJavaScriptRenderer()->addGlobalAssignment(
['TYPO3' => ['configuration' => $t3Configuration]]
);
}
......
......@@ -40,6 +40,7 @@ use TYPO3\CMS\Core\Http\HtmlResponse;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Site\Entity\SiteInterface;
use TYPO3\CMS\Core\Site\Entity\SiteLanguage;
......@@ -685,7 +686,7 @@ class PageLayoutController
$numberOfHiddenElements = $this->getNumberOfHiddenElements($configuration->getLanguageColumns());
$pageActionsCallback = null;
$pageActionsInstruction = JavaScriptModuleInstruction::forRequireJS('TYPO3/CMS/Backend/PageActions');
if ($this->context->isPageEditable()) {
$languageOverlayId = 0;
$pageLocalizationRecord = BackendUtility::getRecordLocalization('pages', $this->id, (int)$this->current_sys_language);
......@@ -695,12 +696,11 @@ class PageLayoutController
if (!empty($pageLocalizationRecord['uid'])) {
$languageOverlayId = $pageLocalizationRecord['uid'];
}
$pageActionsCallback = 'function(PageActions) {
PageActions.setPageId(' . (int)$this->id . ');
PageActions.setLanguageOverlayId(' . $languageOverlayId . ');
}';
$pageActionsInstruction
->invoke('setPageId', (int)$this->id)
->invoke('setLanguageOverlayId', $languageOverlayId);
}
$this->pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/PageActions', $pageActionsCallback);
$this->pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction($pageActionsInstruction);
$tableOutput = GeneralUtility::makeInstance(BackendLayoutRenderer::class, $this->context)->drawContent();
}
......
......@@ -372,10 +372,14 @@ class SiteInlineAjaxController extends AbstractFormEngineAjaxController
if (empty($context['config'])) {
throw new \RuntimeException('Empty context config section given', 1522771632);
}
if (!hash_equals(GeneralUtility::hmac((string)$context['config'], 'InlineContext'), (string)$context['hmac'])) {
$config = json_decode($context['config'], true);
// encode JSON again to ensure same `json_encode()` settings as used when generating original hash
// (side-note: JSON encoded literals differ for target scenarios, e.g. HTML attr, JS string, ...)
$encodedConfig = (string)json_encode($config);
if (!hash_equals(GeneralUtility::hmac($encodedConfig, 'InlineContext'), (string)$context['hmac'])) {
throw new \RuntimeException('Hash does not validate', 1522771640);
}
return json_decode($context['config'], true);
return $config;
}
/**
......
......@@ -206,15 +206,8 @@ class FormResultCompiler
$this->requireJsModules[] = JavaScriptModuleInstruction::forRequireJS('TYPO3/CMS/Backend/FormEngineReview');
foreach ($this->requireJsModules as $moduleName => $callbacks) {
// @todo This is a temporary "solution" and shall be handled in JavaScript directly
if ($callbacks instanceof JavaScriptModuleInstruction) {
$callbackRef = $callbacks->getExportName() ? '__esModule' : 'subjectRef';
$inlineCode = $this->serializeJavaScriptModuleInstructionItems($callbacks);
$callBackFunction = null;
if ($inlineCode !== []) {
$callBackFunction = sprintf('function(%s) { %s }', $callbackRef, implode(' ', $inlineCode));
}
$pageRenderer->loadRequireJsModule($callbacks->getName(), $callBackFunction);
$pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction($callbacks);
continue;
}
......
......@@ -56,6 +56,7 @@ class ModuleTemplate
* Used for inline JS
*
* @var array
* @internal Only used internally, will be removed in TYPO3 v12.0
*/
protected $javascriptCodeArray = [];
......@@ -353,6 +354,7 @@ class ModuleTemplate
/**
* Wrapper function for adding JS inline blocks
* @internal Only used internally, will be removed in TYPO3 v12.0
*/
protected function setJavaScriptCodeArray()
{
......@@ -367,6 +369,7 @@ class ModuleTemplate
* @param string $name Javascript code block name
* @param string $code Inline Javascript
* @return self
* @internal Not used anymore, will be removed in TYPO3 v12.0
*/
public function addJavaScriptCode($name = '', $code = ''): self
{
......
......@@ -38,6 +38,7 @@ use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Messaging\FlashMessage;
use TYPO3\CMS\Core\Messaging\FlashMessageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\Page\PageRenderer;
use TYPO3\CMS\Core\Service\FlexFormService;
use TYPO3\CMS\Core\Site\Entity\NullSite;
......@@ -230,7 +231,8 @@ class PageLayoutView implements LoggerAwareInterface
$this->pageRecord = BackendUtility::getRecordWSOL('pages', $this->id);
$this->pageinfo = BackendUtility::readPageAccess($this->id, '') ?: [];
$pageRenderer = GeneralUtility::makeInstance(PageRenderer::class);
$pageActionsCallback = null;
$pageActionsInstruction = JavaScriptModuleInstruction::forRequireJS('TYPO3/CMS/Backend/PageActions');
if ($this->isPageEditable()) {
$languageOverlayId = 0;
$pageLocalizationRecord = BackendUtility::getRecordLocalization('pages', $this->id, (int)$this->tt_contentConfig['sys_language_uid']);
......@@ -240,12 +242,11 @@ class PageLayoutView implements LoggerAwareInterface
if (!empty($pageLocalizationRecord['uid'])) {
$languageOverlayId = $pageLocalizationRecord['uid'];
}
$pageActionsCallback = 'function(PageActions) {
PageActions.setPageId(' . (int)$this->id . ');
PageActions.setLanguageOverlayId(' . $languageOverlayId . ');
}';
$pageActionsInstruction
->invoke('setPageId', (int)$this->id)
->invoke('setLanguageOverlayId', $languageOverlayId);
}
$pageRenderer->loadRequireJsModule('TYPO3/CMS/Backend/PageActions', $pageActionsCallback);
$pageRenderer->getJavaScriptRenderer()->addJavaScriptModuleInstruction($pageActionsInstruction);
// Get labels for CTypes and tt_content element fields in general:
$this->CType_labels = [];
foreach ($GLOBALS['TCA']['tt_content']['columns']['CType']['config']['items'] as $val) {
......
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Page;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Core\Utility\PathUtility;
class JavaScriptRenderer
{
protected string $handlerUri;
protected ?RequireJS $requireJS = null;
/**
* @var list<array>
*/
protected array $globalAssignments = [];
/**
* @var list<JavaScriptModuleInstruction>
*/
protected array $javaScriptModuleInstructions = [];
public static function create(string $uri = null): self
{
$uri ??= PathUtility::getAbsoluteWebPath(
GeneralUtility::getFileAbsFileName('EXT:core/Resources/Public/JavaScript/JavaScriptHandler.js')
);
return GeneralUtility::makeInstance(static::class, $uri);
}
public function __construct(string $handlerUri)
{
$this->handlerUri = $handlerUri;
}
public function loadRequireJS(RequireJS $requireJS): void
{
$this->requireJS = $requireJS;
}
public function addGlobalAssignment(array $payload): void
{
if (empty($payload)) {
return;
}
$this->globalAssignments[] = $payload;
}
public function addJavaScriptModuleInstruction(JavaScriptModuleInstruction $instruction): void
{
$this->javaScriptModuleInstructions[] = $instruction;
}
/**
* @return list<array{type: string, payload: mixed}>
* @internal
*/
public function toArray(): array
{
if ($this->isEmpty()) {
return [];
}
$items = [];
if ($this->requireJS !== null) {
$items[] = [
'type' => 'loadRequireJS',
'payload' => $this->requireJS,
];
}
foreach ($this->globalAssignments as $item) {
$items[] = [
'type' => 'globalAssignment',
'payload' => $item,
];
}
foreach ($this->javaScriptModuleInstructions as $item) {
$items[] = [
'type' => 'javaScriptModuleInstruction',
'payload' => $item,
];
}
return $items;
}
public function render(): string
{
if ($this->isEmpty()) {
return '';
}
return $this->createScriptElement([
'src' => $this->handlerUri,
'data-process-type' => 'processItems',
], $this->jsonEncode($this->toArray()));
}
protected function isEmpty(): bool
{
return $this->requireJS === null
&& $this->globalAssignments === []
&& empty($this->javaScriptModuleInstructions);
}
protected function createScriptElement(array $attributes, string $textContent = ''): string
{
if (empty($attributes)) {
return '';
}
$attributesPart = GeneralUtility::implodeAttributes($attributes, true);
// actual JSON payload is stored as comment in `script.textContent`
return sprintf('<script %s>/* %s */</script>', $attributesPart, $textContent);
}
protected function jsonEncode($value): string
{
return (string)json_encode($value, JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_TAG);
}
}
......@@ -309,7 +309,7 @@ class PageRenderer implements SingletonInterface
protected $inlineSettings = [];
/**
* @var array
* @var array{0: string, 1: string}
*/
protected $inlineJavascriptWrap = [
'<script type="text/javascript">' . LF . '/*<![CDATA[*/' . LF,
......@@ -336,6 +336,8 @@ class PageRenderer implements SingletonInterface
*/
protected $metaTagRegistry;
protected JavaScriptRenderer $javaScriptRenderer;
/**
* @var FrontendInterface
*/
......@@ -353,6 +355,7 @@ class PageRenderer implements SingletonInterface
}
$this->metaTagRegistry = GeneralUtility::makeInstance(MetaTagManagerRegistry::class);
$this->javaScriptRenderer = JavaScriptRenderer::create();
$this->setMetaTag('name', 'generator', 'TYPO3 CMS');
}
......@@ -406,6 +409,11 @@ class PageRenderer implements SingletonInterface
static::$cache = $cache;
}
public function getJavaScriptRenderer(): JavaScriptRenderer
{
return $this->javaScriptRenderer;
}
/**
* Reset all vars to initial values
*/
......@@ -423,6 +431,7 @@ class PageRenderer implements SingletonInterface
$this->inlineComments = [];
$this->headerData = [];
$this->footerData = [];
$this->javaScriptRenderer = JavaScriptRenderer::create();
}
/*****************************************************/
......@@ -1526,14 +1535,29 @@ class PageRenderer implements SingletonInterface
$this->requireJsConfig
);
}
$requireJS = RequireJS::create(
$this->processJsFile($this->requireJsPath . 'require.js'),
$requireJsConfig
);
// add (probably filtered) RequireJS configuration
$html .= GeneralUtility::wrapJS('var require = ' . json_encode($requireJsConfig)) . LF;
// directly after that, include the require.js file
$html .= '<script src="'
. $this->processJsFile($this->requireJsPath . 'require.js')
. '"></script>' . LF;
if ($this->getApplicationType() === 'BE') {
$html .= sprintf(
'<script src="%s"></script>' . "\n",
htmlspecialchars($requireJS->getUri())
);
// (using dedicated instance of JavaScriptRenderer)
$javaScriptRenderer = JavaScriptRenderer::create();
$javaScriptRenderer->loadRequireJS($requireJS);
$html .= $javaScriptRenderer->render();
} else {
$html .= GeneralUtility::wrapJS('var require = ' . json_encode($requireJS->getConfig())) . LF;
// directly after that, include the require.js file
$html .= sprintf(
'<script src="%s"></script>' . "\n",
htmlspecialchars($requireJS->getUri())
);
}
// use (anonymous require.js loader), e.g. used when not having a valid TYP3 backend user session
if (!empty($requireJsConfig['typo3BaseUrl'])) {
$html .= '<script src="'
. $this->processJsFile(
......@@ -1590,13 +1614,23 @@ class PageRenderer implements SingletonInterface
$this->publicRequireJsConfig['paths'][$baseModuleName] = $this->requireJsConfig['paths'][$baseModuleName];
unset($this->requireJsConfig['paths'][$baseModuleName]);
}
// execute the main module, and load a possible callback function
$javaScriptCode = 'require([' . GeneralUtility::quoteJSvalue($mainModuleName) . ']';
if ($callBackFunction !== null) {
$inlineCodeKey .= sha1($callBackFunction);
$javaScriptCode .= ', ' . $callBackFunction;
if ($callBackFunction === null && $this->getApplicationType() === 'BE') {
$this->javaScriptRenderer->addJavaScriptModuleInstruction(
JavaScriptModuleInstruction::forRequireJS($mainModuleName)
);
return;
}
// processing frontend application or having callback function
// @todo deprecate callback function for backend application in TYPO3 v12.0
if ($callBackFunction === null) {
// just load the main module
$inlineCodeKey = $mainModuleName;
$javaScriptCode = sprintf('require([%s]);', GeneralUtility::quoteJSvalue($mainModuleName));
} else {
// load main module and execute possible callback function
$inlineCodeKey = $mainModuleName . sha1($callBackFunction);
$javaScriptCode = sprintf('require([%s], %s);', GeneralUtility::quoteJSvalue($mainModuleName), $callBackFunction);
}
$javaScriptCode .= ');';
$this->addJsInlineCode('RequireJS-Module-' . $inlineCodeKey, $javaScriptCode);
}
......@@ -2002,19 +2036,29 @@ class PageRenderer implements SingletonInterface
$noBackendUserLoggedIn = empty($GLOBALS['BE_USER']->user['uid']);
$this->addAjaxUrlsToInlineSettings($noBackendUserLoggedIn);
}
$inlineSettings = '';
$languageLabels = $this->parseLanguageLabelsForJavaScript();
if (!empty($languageLabels)) {
$inlineSettings .= 'TYPO3.lang = ' . json_encode($languageLabels) . ';';
$assignments = array_filter([
'settings' => $this->inlineSettings,
'lang' => $this->parseLanguageLabelsForJavaScript(),
]);
if ($assignments === []) {
return '';
}
$inlineSettings .= $this->inlineSettings ? 'TYPO3.settings = ' . json_encode($this->inlineSettings) . ';' : '';
if ($inlineSettings !== '') {
// make sure the global TYPO3 is available
$inlineSettings = 'var TYPO3 = TYPO3 || {};' . CRLF . $inlineSettings;
$out .= $this->inlineJavascriptWrap[0] . $inlineSettings . $this->inlineJavascriptWrap[1];
if ($this->getApplicationType() === 'BE') {
$this->javaScriptRenderer->addGlobalAssignment(['TYPO3' => $assignments]);
$out .= $this->javaScriptRenderer->render();
} else {
$out .= sprintf(
"%svar TYPO3 = Object.assign(TYPO3 || {}, %s);\r\n%s",
$this->inlineJavascriptWrap[0],
// filter potential prototype pollution
sprintf(
'Object.fromEntries(Object.entries(%s).filter((entry) => '
. "!['__proto__', 'prototype', 'constructor'].includes(entry[0])))",
json_encode($assignments)
),
$this->inlineJavascriptWrap[1],
);
}
return $out;
}
......
<?php
declare(strict_types=1);
/*
* This file is part of the TYPO3 CMS project.
*
* It is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License, either version 2
* of the License, or any later version.
*
* For the full copyright and license information, please read the
* LICENSE.txt file that was distributed with this source code.
*
* The TYPO3 project - inspiring people to share!
*/
namespace TYPO3\CMS\Core\Page;
use TYPO3\CMS\Core\Utility\GeneralUtility;
class RequireJS implements \JsonSerializable
{
protected string $uri;
protected array $config;
public static function create(string $uri, array $config): self
{
return GeneralUtility::makeInstance(self::class, $uri, $config);
}
/**
* @param string $uri URI to load require.js implementation
* @param array $config require.js initialization configuration
*/
public function __construct(string $uri, array $config)
{
$this->uri = $uri;
$this->config = $config;
}
public function jsonSerialize(): array
{
return [
'uri' => $this->uri,
'config' => $this->config,
];
}
/**
* @return string
*/
public function getUri(): string
{
return $this->uri;
}
/**
* @return array
*/
public function getConfig(): array
{
return $this->config;
}
}
......@@ -26,6 +26,7 @@ use TYPO3\CMS\Core\Exception;
use TYPO3\CMS\Core\Imaging\Icon;
use TYPO3\CMS\Core\Imaging\IconFactory;
use TYPO3\CMS\Core\Localization\LanguageService;
use TYPO3\CMS\Core\Page\JavaScriptModuleInstruction;
use TYPO3\CMS\Core\TypoScript\Parser\ConstantConfigurationParser;
use TYPO3\CMS\Core\TypoScript\Parser\TypoScriptParser;
use TYPO3\CMS\Core\Utility\ArrayUtility;
......@@ -155,9 +156,10 @@ class ExtendedTemplateService extends TemplateService
public $lastComment = '';
/**
* @var array
* @var array<string, JavaScriptModuleInstruction>
*/
protected $inlineJavaScript = [];
protected $javaScriptInstructions = [];
/**
* @var \TYPO3\CMS\Core\TypoScript\Parser\ConstantConfigurationParser
*/
......@@ -177,13 +179,11 @@ class ExtendedTemplateService extends TemplateService
}
/**
* Gets the inline JavaScript.
*
* @return array
* @return array<string, JavaScriptModuleInstruction>
*/