From 64db88b9b61bb67b3b44145dc8e0e1ef251da45e Mon Sep 17 00:00:00 2001 From: Oliver Hader Date: Tue, 17 Dec 2019 10:51:30 +0100 Subject: [PATCH] [SECURITY] Avoid XSS by correctly encoding typolink results In order to avoid XSS through typolink, anchor text is encoded correctly to be used in a HTML context. Fallback link texts of links to pages are encoded per default in case lib.parseFunc has not been configured. Resolves: #88635 Releases: master, 9.5, 8.7 Security-Commit: 57c5eeb93e6df4b1958bcafcd85ada6c7e355d41 Security-Bulletin: TYPO3-CORE-SA-2019-022 Change-Id: I9a415d6b2ed494dac7f4747e25460d95e1f27284 Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/62704 Tested-by: Oliver Hader Reviewed-by: Oliver Hader --- .../Classes/LinkHandling/UrlLinkHandler.php | 1 + .../ContentObject/ContentObjectRenderer.php | 2 +- .../Typolink/AbstractTypolinkBuilder.php | 37 +- .../Typolink/ExternalUrlLinkBuilder.php | 2 +- .../Typolink/FileOrFolderLinkBuilder.php | 2 +- .../Classes/Typolink/LegacyLinkBuilder.php | 4 +- .../Fixtures/TypoLinkScenario.yaml | 40 ++ .../SiteHandling/TypoLinkGeneratorTest.php | 453 +++++++++++++++++- 8 files changed, 509 insertions(+), 32 deletions(-) create mode 100644 typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/TypoLinkScenario.yaml diff --git a/typo3/sysext/core/Classes/LinkHandling/UrlLinkHandler.php b/typo3/sysext/core/Classes/LinkHandling/UrlLinkHandler.php index aa9e05cbc112..f8008fcbdfcb 100644 --- a/typo3/sysext/core/Classes/LinkHandling/UrlLinkHandler.php +++ b/typo3/sysext/core/Classes/LinkHandling/UrlLinkHandler.php @@ -54,6 +54,7 @@ protected function addHttpSchemeAsFallback(string $url): string $scheme = parse_url($url, PHP_URL_SCHEME); if (empty($scheme)) { $url = 'http://' . $url; + // 'java{TAB}script:' is parsed as empty URL scheme, thus not ending up here } elseif (in_array(strtolower($scheme), ['javascript', 'data'], true)) { // deny using insecure scheme's like `javascript:` or `data:` as URL scheme $url = ''; diff --git a/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php b/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php index 29fdb4d2c8e0..8efbaf41d7e3 100644 --- a/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php +++ b/typo3/sysext/frontend/Classes/ContentObject/ContentObjectRenderer.php @@ -5349,7 +5349,7 @@ protected function resolveMixedLinkParameter($linkText, $mixedLinkParameter, &$c // Resource was not found return $linkText; } - } elseif (in_array(strtolower(trim($linkHandlerKeyword)), ['javascript', 'data'], true)) { + } elseif (in_array(strtolower(preg_replace('#\s|[[:cntrl:]]#', '', $linkHandlerKeyword)), ['javascript', 'data'], true)) { // Disallow insecure scheme's like javascript: or data: return $linkText; } else { diff --git a/typo3/sysext/frontend/Classes/Typolink/AbstractTypolinkBuilder.php b/typo3/sysext/frontend/Classes/Typolink/AbstractTypolinkBuilder.php index 1b83a471c23f..8363e097cbc2 100644 --- a/typo3/sysext/frontend/Classes/Typolink/AbstractTypolinkBuilder.php +++ b/typo3/sysext/frontend/Classes/Typolink/AbstractTypolinkBuilder.php @@ -109,6 +109,20 @@ protected function forceAbsoluteUrl(string $url, array $configuration): string return $url; } + /** + * Determines whether lib.parseFunc is defined. + * + * @return bool + */ + protected function isLibParseFuncDefined(): bool + { + $configuration = $this->contentObjectRenderer->mergeTSRef( + ['parseFunc' => '< lib.parseFunc'], + 'parseFunc' + ); + return !empty($configuration['parseFunc.']) && is_array($configuration['parseFunc.']); + } + /** * Helper method to a fallback method parsing HTML out of it * @@ -118,10 +132,29 @@ protected function forceAbsoluteUrl(string $url, array $configuration): string */ protected function parseFallbackLinkTextIfLinkTextIsEmpty(string $originalLinkText, string $fallbackLinkText): string { - if ($originalLinkText === '') { + if ($originalLinkText !== '') { + return $originalLinkText; + } + if ($this->isLibParseFuncDefined()) { return $this->contentObjectRenderer->parseFunc($fallbackLinkText, ['makelinks' => 0], '< lib.parseFunc'); } - return $originalLinkText; + // encode in case `lib.parseFunc` is not configured + return $this->encodeFallbackLinkTextIfLinkTextIsEmpty($originalLinkText, $fallbackLinkText); + } + + /** + * Helper method to a fallback method properly encoding HTML. + * + * @param string $originalLinkText the original string, if empty, the fallback link text + * @param string $fallbackLinkText the string to be used. + * @return string the final text + */ + protected function encodeFallbackLinkTextIfLinkTextIsEmpty(string $originalLinkText, string $fallbackLinkText): string + { + if ($originalLinkText !== '') { + return $originalLinkText; + } + return htmlspecialchars($fallbackLinkText, ENT_QUOTES); } /** diff --git a/typo3/sysext/frontend/Classes/Typolink/ExternalUrlLinkBuilder.php b/typo3/sysext/frontend/Classes/Typolink/ExternalUrlLinkBuilder.php index a20d11f0a802..9b03bb9657ba 100644 --- a/typo3/sysext/frontend/Classes/Typolink/ExternalUrlLinkBuilder.php +++ b/typo3/sysext/frontend/Classes/Typolink/ExternalUrlLinkBuilder.php @@ -28,7 +28,7 @@ public function build(array &$linkDetails, string $linkText, string $target, arr { return [ $this->processUrl(UrlProcessorInterface::CONTEXT_EXTERNAL, $linkDetails['url'], $conf), - $this->parseFallbackLinkTextIfLinkTextIsEmpty($linkText, $linkDetails['url']), + $this->encodeFallbackLinkTextIfLinkTextIsEmpty($linkText, $linkDetails['url']), $target ?: $this->resolveTargetAttribute($conf, 'extTarget', true, $this->getTypoScriptFrontendController()->extTarget) ]; } diff --git a/typo3/sysext/frontend/Classes/Typolink/FileOrFolderLinkBuilder.php b/typo3/sysext/frontend/Classes/Typolink/FileOrFolderLinkBuilder.php index 1c1c8d1f584e..b9a9da29373b 100644 --- a/typo3/sysext/frontend/Classes/Typolink/FileOrFolderLinkBuilder.php +++ b/typo3/sysext/frontend/Classes/Typolink/FileOrFolderLinkBuilder.php @@ -47,7 +47,7 @@ public function build(array &$linkDetails, string $linkText, string $target, arr $linkLocation = ''; } // Setting title if blank value to link - $linkText = $this->parseFallbackLinkTextIfLinkTextIsEmpty($linkText, rawurldecode($linkLocation)); + $linkText = $this->encodeFallbackLinkTextIfLinkTextIsEmpty($linkText, rawurldecode($linkLocation)); if (strpos($linkLocation, '/') !== 0 && parse_url($linkLocation, PHP_URL_SCHEME) === null ) { diff --git a/typo3/sysext/frontend/Classes/Typolink/LegacyLinkBuilder.php b/typo3/sysext/frontend/Classes/Typolink/LegacyLinkBuilder.php index 12c2b1a622bb..c3dcfd300a5d 100644 --- a/typo3/sysext/frontend/Classes/Typolink/LegacyLinkBuilder.php +++ b/typo3/sysext/frontend/Classes/Typolink/LegacyLinkBuilder.php @@ -33,7 +33,7 @@ public function build(array &$linkDetails, string $linkText, string $target, arr $linkDetails['type'] = LinkService::TYPE_FILE; $linkLocation = $linkDetails['file']; // Setting title if blank value to link - $linkText = $this->parseFallbackLinkTextIfLinkTextIsEmpty($linkText, rawurldecode($linkLocation)); + $linkText = $this->encodeFallbackLinkTextIfLinkTextIsEmpty($linkText, rawurldecode($linkLocation)); $linkLocation = (strpos($linkLocation, '/') !== 0 ? $tsfe->absRefPrefix : '') . $linkLocation; $url = $this->processUrl(UrlProcessorInterface::CONTEXT_FILE, $linkLocation, $conf); $url = $this->forceAbsoluteUrl($url, $conf); @@ -41,7 +41,7 @@ public function build(array &$linkDetails, string $linkText, string $target, arr } elseif ($linkDetails['url']) { $linkDetails['type'] = LinkService::TYPE_URL; $target = $target ?: $this->resolveTargetAttribute($conf, 'extTarget', true, $tsfe->extTarget); - $linkText = $this->parseFallbackLinkTextIfLinkTextIsEmpty($linkText, $linkDetails['url']); + $linkText = $this->encodeFallbackLinkTextIfLinkTextIsEmpty($linkText, $linkDetails['url']); $url = $this->processUrl(UrlProcessorInterface::CONTEXT_EXTERNAL, $linkDetails['url'], $conf); } else { throw new UnableToLinkException('Unknown link detected, so ' . $linkText . ' was not linked.', 1490990031, null, $linkText); diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/TypoLinkScenario.yaml b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/TypoLinkScenario.yaml new file mode 100644 index 000000000000..19edddb2cd48 --- /dev/null +++ b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Fixtures/TypoLinkScenario.yaml @@ -0,0 +1,40 @@ +__variables: + - &pageStandard 0 + - &pageShortcut 4 + +entitySettings: + '*': + nodeColumnName: 'pid' + columnNames: {id: 'uid', language: 'sys_language_uid'} + defaultValues: {pid: 0} + page: + isNode: true + tableName: 'pages' + parentColumnName: 'pid' + languageColumnNames: ['l10n_parent', 'l10n_source'] + columnNames: {type: 'doktype', root: 'is_siteroot'} + defaultValues: {hidden: 0, doktype: *pageStandard} + valueInstructions: + shortcut: + first: {shortcut: 0, shortcut_mode: 1} + content: + tableName: 'tt_content' + languageColumnNames: ['l18n_parent', 'l10n_source'] + columnNames: {title: 'header', type: 'CType'} + defaultValues: {hidden: 0, CType: 'text'} + +entities: + page: + - self: {id: 1000, title: 'ACME Inc', type: *pageShortcut, shortcut: 'first', root: true, slug: '/'} + children: + - self: {id: 1100, title: 'EN: Welcome', slug: '/welcome', subtitle: 'hello-and-welcome'} + entities: + content: + - self: {title: 'EN: Content Element #1'} + - self: {title: 'EN: Content Element #2'} + - self: {id: 1200, title: 'EN: Features', slug: '/features'} + - self: {id: 9911, title: '', slug: '/test/good'} + - self: {id: 9912, title: '', slug: '/test/good-a-b-spaced'} + - self: {id: 9913, title: '', slug: '/test/good-a-b-enc-a'} + - self: {id: 9914, title: '', slug: '/test/good-a-b-enc-b'} + - self: {id: 9921, title: '', slug: '/test/bad'} diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/TypoLinkGeneratorTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/TypoLinkGeneratorTest.php index 557152a5fcc7..e22b4f70c0ce 100644 --- a/typo3/sysext/frontend/Tests/Functional/SiteHandling/TypoLinkGeneratorTest.php +++ b/typo3/sysext/frontend/Tests/Functional/SiteHandling/TypoLinkGeneratorTest.php @@ -24,10 +24,12 @@ use TYPO3\CMS\Frontend\Tests\Functional\SiteHandling\Fixtures\LinkHandlingController; use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerFactory; use TYPO3\TestingFramework\Core\Functional\Framework\DataHandling\Scenario\DataHandlerWriter; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\Internal\AbstractInstruction; use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\Internal\ArrayValueInstruction; use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\Internal\TypoScriptInstruction; use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest; use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequestContext; +use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalResponse; /** * Test case for build URLs with TypoLink via Frontend Request. @@ -88,7 +90,7 @@ protected function setUpDatabase() $backendUser = $this->setUpBackendUserFromFixture(1); Bootstrap::initializeLanguageObject(); - $scenarioFile = __DIR__ . '/Fixtures/SlugScenario.yaml'; + $scenarioFile = __DIR__ . '/Fixtures/TypoLinkScenario.yaml'; $factory = DataHandlerFactory::fromYamlFile($scenarioFile); $writer = DataHandlerWriter::withBackendUser($backendUser); $writer->invokeFactory($factory); @@ -160,6 +162,10 @@ public function linkIsGeneratedDataProvider(): array 't3://email?email=user@example.org&other=other#other', 'user@example.org', ], + [ + 't3://email?email=user@example.org?subject=Hello%20World#other', + 'user@example.org?subject=Hello World', + ], [ 't3://file?uid=1&type=1&other=other#other', 'fileadmin/logo.png', @@ -185,8 +191,16 @@ public function linkIsGeneratedDataProvider(): array 'EN: Features', ], [ - 't3://url?url=https://typo3.org&other=other#other', - 'https://typo3.org', + 't3://url?url=https://typo3.org%3f%26param-a=a%26param-b=b&other=other#other', + 'https://typo3.org?&param-a=a&param-b=b', + ], + [ + '1200,1 target class title ¶m-a=a', + 'EN: Features' + ], + [ + 'user@example.org target class title &other=other', + 'user@example.org' ], ]; return $this->keysFromTemplate($instructions, '%1$s;'); @@ -201,36 +215,303 @@ public function linkIsGeneratedDataProvider(): array */ public function linkIsGenerated(string $parameter, string $expectation) { - $sourcePageId = 1100; + $response = $this->invokeTypoLink($parameter); + self::assertSame($expectation, (string)$response->getBody()); + } - $response = $this->executeFrontendRequest( - (new InternalRequest('https://acme.us/')) - ->withPageId($sourcePageId) - ->withInstructions([ - (new TypoScriptInstruction(TemplateService::class))->withTypoScript([ - 'config.' => [ - 'recordLinks.' => [ - 'content.' => [ - 'forceLink' => 1, - 'typolink.' => [ - 'parameter' => 1200, - 'section.' => [ - 'data' => 'field:uid', - 'wrap' => 'c|', - ], - ], - ], + /** + * @return array + */ + public function linkIsEncodedDataProvider(): array + { + $instructions = [ + [ + 't3://email?email=mailto:thing(1)', + '<bad>thing(1)</bad>', + ], + [ + 't3://email?email=mailto:', + '<good a="a/" b="thing(1)">', + ], + [ + 't3://email?email=thing(1)', + '<bad>thing(1)</bad>', + ], + [ + 't3://email?email=', + '<good a="a/" b="thing(1)">', + ], + [ + 't3://folder?identifier=', + '', + ], + [ + 't3://page?uid=', + '', + ], + [ + 't3://record?identifier=content&uid=', + '', + ], + [ + 't3://url?url=thing(1)', + 'http://<bad>thing(1)</bad>' + ], + [ + 't3://url?url=', + 'http://<good a="a/" b="thing(1)">' + ], + [ + 't3://url?url=javascript:good()', + '' + ], + [ + "t3://url?url=java\tscript:good()", + 'http://java_script:good()' + ], + [ + 't3://url?url=java script:good()', + 'http://java' + ], + [ + 't3://url?url=javascript:good()', + 'http://javascript' + ], + [ + 't3://url?url=data:text/html,', + '' + ], + [ + "t3://url?url=da\tsta:text/html,", + 'http://da_sta:text/html,<good>' + ], + [ + 't3://url?url=da ta:text/html,', + 'http://da' + ], + [ + 't3://url?url=data:text/html,', + 'http://data' + ], + [ + 't3://url?url=%26%23106%3B%26%2397%3B%26%23118%3B%26%2397%3B%26%23115%3B%26%2399%3B%26%23114%3B%26%23105%3B%26%23112%3B%26%23116%3B%26%2358%3B%26%23103%3B%26%23111%3B%26%23111%3B%26%23100%3B%26%2340%3B%26%2341%3B', + 'http://&#106;&#97;&#118;&#97;&#115;&#99;&#114;&#105;&#112;&#116;&#58;&#103;&#111;&#111;&#100;&#40;&#41;', + ], + [ + 'thing(1)', + '<bad>thing(1)</bad>' + ], + [ + '', + '<good a="a/" b="thing(1)">' + ], + [ + ' target class title &other=other', + '<good/a="a/"/b="thing(1)">' + ], + [ + 'javascript:good()', + '', + ], + [ + "java\tscript:good()", + '', + ], + [ + 'java script:good()', + '' + ], + [ + 'javascript:good()', + '' + ], + [ + 'data:text/html,', + '', + ], + [ + "da\tta:text/html,", + '', + ], + [ + 'da ta:text/html,', + '', + ], + [ + 'data:text/html,', + 'data&colon;text/html,<good>', + ], + [ + '%26%23106%3B%26%2397%3B%26%23118%3B%26%2397%3B%26%23115%3B%26%2399%3B%26%23114%3B%26%23105%3B%26%23112%3B%26%23116%3B%26%2358%3B%26%23103%3B%26%23111%3B%26%23111%3B%26%23100%3B%26%2340%3B%26%2341%3B', + '', + ], + [ + ' <"> <"> <">', + '</>', + ], + ]; + return $this->keysFromTemplate($instructions, '%1$s;'); + } + + /** + * @param string $parameter + * @param string $expectation + * + * @test + * @dataProvider linkIsEncodedDataProvider + */ + public function linkIsEncodedPerDefault(string $parameter, string $expectation) + { + $response = $this->invokeTypoLink($parameter); + self::assertSame($expectation, (string)$response->getBody()); + } + + /** + * @param string $parameter + * @param string $expectation + * + * @test + * @dataProvider linkIsEncodedDataProvider + */ + public function linkIsEncodedHavingParseFunc(string $parameter, string $expectation) + { + $response = $this->invokeTypoLink($parameter, $this->createParseFuncInstruction([ + 'allowTags' => 'good', + 'denyTags' => '*', + 'nonTypoTagStdWrap.' => [ + 'HTMLparser.' => [ + 'tags.' => [ + 'good.' => [ + 'allowedAttribs' => 0, + ], + ], + ], + ], + ])); + self::assertSame($expectation, (string)$response->getBody()); + } + + /** + * @return array + */ + public function linkToPageIsProcessedDataProvider(): array + { + return [ + [ + 't3://page?uid=9911', + '<good>', + false, + ], + [ + 't3://page?uid=9911', + '', + true, + ], + [ + 't3://page?uid=9912', + '<good a="a/" b="thing(1)">', + false, + ], + [ + 't3://page?uid=9912', + '', + true, + ], + [ + 't3://page?uid=9913', + '<good%20a="a/"%20b="thing(1)">', + false, + ], + [ + 't3://page?uid=9913', + '<good%20a="a/"%20b="thing(1)">', + true, + ], + [ + 't3://page?uid=9914', + '<good/a="a/"/b="thing(1)">', + false, + ], + [ + 't3://page?uid=9914', + '<good/a="a/"/b="thing(1)">', + true, + ], + [ + 't3://page?uid=9921', + '<bad>', + false, + ], + [ + 't3://page?uid=9921', + '<bad>', + true, + ], + ]; + } + + /** + * @param string $parameter + * @param string $expectation + * @param bool $parseFuncEnabled + * + * @test + * @dataProvider linkToPageIsProcessedDataProvider + */ + public function linkToPageIsProcessed(string $parameter, string $expectation, bool $parseFuncEnabled) + { + $instructions = []; + if ($parseFuncEnabled) { + $instructions[] = $this->createParseFuncInstruction([ + 'allowTags' => 'good', + 'denyTags' => '*', + 'nonTypoTagStdWrap.' => [ + 'HTMLparser.' => [ + 'tags.' => [ + 'good.' => [ + 'allowedAttribs' => 0, ], ], + ], + ], + ]); + } + $response = $this->invokeTypoLink($parameter, ...$instructions); + self::assertSame($expectation, (string)$response->getBody()); + } + + /** + * @param string $parameter + * @param AbstractInstruction ...$instructions + * @return InternalResponse + */ + private function invokeTypoLink(string $parameter, AbstractInstruction ...$instructions): InternalResponse + { + $sourcePageId = 1100; + $targetPageId = 1200; + + $request = (new InternalRequest('https://acme.us/')) + ->withPageId($sourcePageId) + ->withInstructions( + [ + $this->createRecordLinksInstruction([ + 'parameter' => $targetPageId, + 'section.' => [ + 'data' => 'field:uid', + 'wrap' => 'c|', + ], ]), $this->createTypoLinkInstruction([ 'parameter' => $parameter, ]) - ]), - $this->internalRequestContext - ); + ] + ); + + if (count($instructions) > 0) { + $request = $this->applyInstructions($request, ...$instructions); + } - static::assertSame($expectation, (string)$response->getBody()); + return $this->executeFrontendRequest($request, $this->internalRequestContext); } /** @@ -247,4 +528,126 @@ private function createTypoLinkInstruction(array $typoLink): ArrayValueInstructi ] ]); } + + /** + * @param array $typoLink + * @return TypoScriptInstruction + */ + private function createRecordLinksInstruction(array $typoLink): TypoScriptInstruction + { + return (new TypoScriptInstruction(TemplateService::class)) + ->withTypoScript([ + 'config.' => [ + 'recordLinks.' => [ + 'content.' => [ + 'forceLink' => 1, + 'typolink.' => $typoLink, + ], + ], + ], + ]); + } + + /** + * @param array $parseFunc + * @return TypoScriptInstruction + */ + private function createParseFuncInstruction(array $parseFunc): TypoScriptInstruction + { + return (new TypoScriptInstruction(TemplateService::class)) + ->withTypoScript([ + 'lib.' => [ + 'parseFunc.' => array_replace_recursive( + [ + 'makelinks' => 1, + 'makelinks.' => [ + 'http.' => [ + 'keep' => 'path', + 'extTarget' => '_blank', + ], + 'mailto.' => [ + 'keep' => 'path', + ], + ], + 'allowTags' => '', + 'denyTags' => '', + 'constants' => 1, + 'nonTypoTagStdWrap.' => [ + 'HTMLparser' => 1, + 'HTMLparser.' => [ + 'keepNonMatchedTags' => 1, + 'htmlSpecialChars' => 2, + ], + ], + ], + $parseFunc + ), + ], + ]); + } + + /** + * @param InternalRequest $request + * @param AbstractInstruction ...$instructions + * @return InternalRequest + * + * @todo Instruction handling should be part of Testing Framework (multiple instructions per identifier, merge in interface) + */ + private function applyInstructions(InternalRequest $request, AbstractInstruction ...$instructions): InternalRequest + { + $modifiedInstructions = []; + + foreach ($instructions as $instruction) { + $identifier = $instruction->getIdentifier(); + if (isset($modifiedInstructions[$identifier]) || $request->getInstruction($identifier) !== null) { + $modifiedInstructions[$identifier] = $this->mergeInstruction( + $modifiedInstructions[$identifier] ?? $request->getInstruction($identifier), + $instruction + ); + } else { + $modifiedInstructions[$identifier] = $instruction; + } + } + + return $request->withInstructions($modifiedInstructions); + } + + /** + * @param AbstractInstruction $current + * @param AbstractInstruction $other + * @return AbstractInstruction + */ + private function mergeInstruction(AbstractInstruction $current, AbstractInstruction $other): AbstractInstruction + { + if (get_class($current) !== get_class($other)) { + throw new \LogicException('Cannot merge different instruction types', 1565863174); + } + + if ($current instanceof TypoScriptInstruction) { + /** @var $other TypoScriptInstruction */ + $typoScript = array_replace_recursive( + $current->getTypoScript() ?? [], + $other->getTypoScript() ?? [] + ); + $constants = array_replace_recursive( + $current->getConstants() ?? [], + $other->getConstants() ?? [] + ); + if ($typoScript !== []) { + $current = $current->withTypoScript($typoScript); + } + if ($constants !== []) { + $current = $current->withConstants($constants); + } + return $current; + } + + if ($current instanceof ArrayValueInstruction) { + /** @var $other ArrayValueInstruction */ + $array = array_merge_recursive($current->getArray(), $other->getArray()); + return $current->withArray($array); + } + + return $current; + } }