Skip to content

Commit

Permalink
[BUGFIX] Properly decode cross domain links (#743)
Browse files Browse the repository at this point in the history
Resolves: #741
  • Loading branch information
twoldanski authored Jul 2, 2024
1 parent c133e52 commit 3f6743f
Show file tree
Hide file tree
Showing 2 changed files with 139 additions and 46 deletions.
114 changes: 106 additions & 8 deletions Classes/Event/Listener/AfterLinkIsGeneratedListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,18 @@
namespace FriendsOfTYPO3\Headless\Event\Listener;

use FriendsOfTYPO3\Headless\Utility\HeadlessFrontendUrlInterface;
use Psr\Log\LoggerInterface;
use Throwable;
use TYPO3\CMS\Core\LinkHandling\Exception\UnknownLinkHandlerException;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\Site\Entity\NullSite;
use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
use TYPO3\CMS\Core\Resource\Exception\InvalidPathException;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Core\Utility\GeneralUtility;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
use TYPO3\CMS\Frontend\Event\AfterLinkIsGeneratedEvent;
use TYPO3\CMS\Frontend\Typolink\UnableToLinkException;

use function is_numeric;
use function is_string;
Expand All @@ -24,8 +32,11 @@
final class AfterLinkIsGeneratedListener
{
public function __construct(
private readonly LoggerInterface $logger,
private readonly HeadlessFrontendUrlInterface $urlUtility,
private readonly LinkService $linkService
private readonly LinkService $linkService,
private readonly TypoLinkCodecService $typoLinkCodecService,
private readonly SiteFinder $siteFinder
) {}

public function __invoke(AfterLinkIsGeneratedEvent $event): void
Expand All @@ -50,18 +61,17 @@ public function __invoke(AfterLinkIsGeneratedEvent $event): void
(int)$pageId
);
} else {
/**
* @var Site $site
*/
$site = $event->getContentObjectRenderer()->getRequest()->getAttribute('site');
$key = 'frontendBase';
try {
$site = $this->getTargetSite($event);
$key = 'frontendBase';

if (!$site instanceof NullSite) {
if (is_string($pageId) && str_starts_with($pageId, 't3://page?uid=current&type=' . $site->getSettings()->get('headless.sitemap.type', '1533906435'))) {
$key = $site->getSettings()->get('headless.sitemap.key', 'frontendApiProxy');
}

$href = $urlUtility->getFrontendUrlWithSite($event->getLinkResult()->getUrl(), $site, $key);
} catch (Throwable $e) {
$this->logger->error($e->getMessage());
}
}

Expand All @@ -70,4 +80,92 @@ public function __invoke(AfterLinkIsGeneratedEvent $event): void
$event->setLinkResult($result);
}
}

private function getTargetSite(AfterLinkIsGeneratedEvent $event): Site
{
$linkConfiguration = $event->getLinkResult()->getLinkConfiguration();

if (isset($linkConfiguration['parameter.'])) {
// Evaluate "parameter." stdWrap but keep additional information (like target, class and title)
$linkParameterParts = $this->typoLinkCodecService->decode($linkConfiguration['parameter'] ?? '');
$modifiedLinkParameterString = $event->getContentObjectRenderer()->stdWrap($linkParameterParts['url'], $linkConfiguration['parameter.']);
// As the stdWrap result might contain target etc. as well again (".field = header_link")
// the result is then taken from the stdWrap and overridden if the value is not empty.
$modifiedLinkParameterParts = $this->typoLinkCodecService->decode((string)($modifiedLinkParameterString ?? ''));
$linkParameterParts = array_replace($linkParameterParts, array_filter($modifiedLinkParameterParts, static fn($value) => trim((string)$value) !== ''));
$linkParameter = $this->typoLinkCodecService->encode($linkParameterParts);
} else {
$linkParameter = trim((string)($linkConfiguration['parameter'] ?? ''));
}

try {
[$linkParameter] = $this->resolveTypolinkParameterString($linkParameter, $linkConfiguration);
} catch (UnableToLinkException $e) {
$this->logger->warning($e->getMessage(), ['linkConfiguration' => $linkConfiguration]);
throw $e;
}
$linkDetails = $this->resolveLinkDetails($linkParameter, $linkConfiguration, $event->getContentObjectRenderer());
if ($linkDetails === null) {
throw new UnableToLinkException('Could not resolve link details from ' . $linkParameter, 1642001442, null, $event->getLinkResult()->getLinkText());
}

if (($linkDetails['pageuid'] ?? '') === 'current') {
return $event->getContentObjectRenderer()->getRequest()->getAttribute('site');
}

return $this->siteFinder->getSiteByPageId((int)$linkDetails['pageuid']);
}

protected function resolveLinkDetails(string $linkParameter, array $linkConfiguration, ContentObjectRenderer $contentObjectRenderer): ?array
{
$linkDetails = null;
if (!$linkParameter) {
// Support anchors without href value if id or name attribute is present.
$aTagParams = (string)$contentObjectRenderer->stdWrapValue('ATagParams', $linkConfiguration);
$aTagParams = GeneralUtility::get_tag_attributes($aTagParams);
// If it looks like an anchor tag, render it anyway
if (isset($aTagParams['id']) || isset($aTagParams['name'])) {
$linkDetails = [
'type' => LinkService::TYPE_INPAGE,
'url' => '',
];
}
} else {
// Detecting kind of link and resolve all necessary parameters
try {
$linkDetails = $this->linkService->resolve($linkParameter);
} catch (UnknownLinkHandlerException|InvalidPathException $exception) {
$this->logger->warning('The link could not be generated', ['exception' => $exception]);
return null;
}
}
if (is_array($linkDetails)) {
$linkDetails['typoLinkParameter'] = $linkParameter;
}
return $linkDetails;
}

private function resolveTypolinkParameterString(string $mixedLinkParameter, array &$linkConfiguration = []): array
{
$linkParameterParts = $this->typoLinkCodecService->decode($mixedLinkParameter);
[$linkHandlerKeyword] = explode(':', $linkParameterParts['url'] ?? '', 2);
if (in_array(strtolower((string)preg_replace('#\s|[[:cntrl:]]#', '', (string)$linkHandlerKeyword)), ['javascript', 'data'], true)) {
// Disallow insecure scheme's like javascript: or data:
throw new UnableToLinkException('Insuecure scheme for linking detected with "' . $mixedLinkParameter . "'", 1641986533);
}

// additional parameters that need to be set
if (($linkParameterParts['additionalParams'] ?? '') !== '') {
$forceParams = $linkParameterParts['additionalParams'];
// params value
$linkConfiguration['additionalParams'] = ($linkConfiguration['additionalParams'] ?? '') . $forceParams[0] === '&' ? $forceParams : '&' . $forceParams;
}

return [
$linkParameterParts['url'] ?? '',
$linkParameterParts['target'] ?? '',
$linkParameterParts['class'] ?? '',
$linkParameterParts['title'] ?? '',
];
}
}
71 changes: 33 additions & 38 deletions Tests/Unit/Event/Listener/AfterLinkIsGeneratedListenerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
use TYPO3\CMS\Core\ExpressionLanguage\Resolver;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\LinkHandling\LinkService;
use TYPO3\CMS\Core\LinkHandling\TypoLinkCodecService;
use TYPO3\CMS\Core\Log\Logger;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Site\SiteFinder;
use TYPO3\CMS\Frontend\ContentObject\ContentObjectRenderer;
Expand All @@ -34,8 +36,11 @@ public function test__construct()
$siteFinder = $this->prophesize(SiteFinder::class);

$listener = new AfterLinkIsGeneratedListener(
$this->prophesize(Logger::class)->reveal(),
new UrlUtility(null, $resolver->reveal(), $siteFinder->reveal()),
$this->prophesize(LinkService::class)->reveal()
$this->prophesize(LinkService::class)->reveal(),
new TypoLinkCodecService(),
$this->prophesize(SiteFinder::class)->reveal()
);

self::assertInstanceOf(AfterLinkIsGeneratedListener::class, $listener);
Expand All @@ -48,13 +53,17 @@ public function test__invokeNotModifingAnything()
$siteFinder = $this->prophesize(SiteFinder::class);

$listener = new AfterLinkIsGeneratedListener(
$this->prophesize(Logger::class)->reveal(),
new UrlUtility(null, $resolver->reveal(), $siteFinder->reveal()),
$this->prophesize(LinkService::class)->reveal()
$this->prophesize(LinkService::class)->reveal(),
new TypoLinkCodecService(),
$this->prophesize(SiteFinder::class)->reveal()
);

$site = new Site('test', 1, []);
$cObj = $this->prophesize(ContentObjectRenderer::class);
$cObj->getRequest()->willReturn((new ServerRequest())->withAttribute('site', $site));
$cObj->stdWrapValue(Argument::is('ATagParams'), Argument::is([]))->willReturn('');

$linkResult = new LinkResult('page', '/');
$linkResult = $linkResult->withLinkText('|');
Expand Down Expand Up @@ -97,11 +106,15 @@ public function test__invokeModifingFromPageUid()
$urlUtility->withRequest($request)->willReturn($urlUtility->reveal());

$listener = new AfterLinkIsGeneratedListener(
$this->prophesize(Logger::class)->reveal(),
$urlUtility->reveal(),
$this->prophesize(LinkService::class)->reveal()
$this->prophesize(LinkService::class)->reveal(),
new TypoLinkCodecService(),
$this->prophesize(SiteFinder::class)->reveal()
);

$linkResult = new LinkResult('page', '/');
$linkResult = $linkResult->withLinkConfiguration(['parameter' => 2]);
$linkResult = $linkResult->withLinkText('t3://page?uid=2');

$event = new AfterLinkIsGeneratedEvent($linkResult, $cObj->reveal(), []);
Expand All @@ -110,39 +123,6 @@ public function test__invokeModifingFromPageUid()
self::assertSame('https://frontend-domain.tld/page', $event->getLinkResult()->getUrl());
}

public function test__invokeModifingWithoutPageId()
{
$resolver = $this->prophesize(Resolver::class);
$resolver->evaluate(Argument::any())->willReturn(true);

$site = new Site('test', 1, []);

$urlUtility = $this->prophesize(UrlUtility::class);
$urlUtility->getFrontendUrlWithSite(
Argument::is('/'),
Argument::is($site),
Argument::is('frontendBase')
)->willReturn('https://front.typo3.tld');

$cObj = $this->prophesize(ContentObjectRenderer::class);
$request = (new ServerRequest())->withAttribute('site', $site);
$cObj->getRequest()->willReturn($request);

$urlUtility->withRequest($request)->willReturn($urlUtility->reveal());

$listener = new AfterLinkIsGeneratedListener(
$urlUtility->reveal(),
$this->prophesize(LinkService::class)->reveal()
);
$linkResult = new LinkResult('page', '/');
$linkResult = $linkResult->withLinkText('|');

$event = new AfterLinkIsGeneratedEvent($linkResult, $cObj->reveal(), []);
$listener($event);

self::assertSame('https://front.typo3.tld', $event->getLinkResult()->getUrl());
}

public function test__invokeModifingExternalSite()
{
$resolver = $this->prophesize(Resolver::class);
Expand All @@ -162,7 +142,13 @@ public function test__invokeModifingExternalSite()

$urlUtility->withRequest($request)->willReturn($urlUtility->reveal());

$listener = new AfterLinkIsGeneratedListener($urlUtility->reveal(), $linkService->reveal());
$listener = new AfterLinkIsGeneratedListener(
$this->prophesize(Logger::class)->reveal(),
$urlUtility->reveal(),
$linkService->reveal(),
new TypoLinkCodecService(),
$this->prophesize(SiteFinder::class)->reveal()
);
$linkResult = new LinkResult('page', '/');
$linkResult = $linkResult->withLinkConfiguration(['parameter.' => ['data' => 'parameters:href']]);
$linkResult = $linkResult->withLinkText('|');
Expand Down Expand Up @@ -195,9 +181,18 @@ public function test__SitemapLink()
$request = (new ServerRequest())->withAttribute('site', $site);
$cObj->getRequest()->willReturn($request);

$siteFinder = $this->prophesize(SiteFinder::class);
$siteFinder->getSiteByPageId(Argument::any())->willReturn($site);

$urlUtility->withRequest($request)->willReturn($urlUtility->reveal());

$listener = new AfterLinkIsGeneratedListener($urlUtility->reveal(), $linkService->reveal());
$listener = new AfterLinkIsGeneratedListener(
$this->prophesize(Logger::class)->reveal(),
$urlUtility->reveal(),
$linkService->reveal(),
new TypoLinkCodecService(),
$siteFinder->reveal()
);

$linkResult = new LinkResult('page', 'https://typo3.tld/sitemap-type/pages/sitemap.xml');
$linkResult = $linkResult->withLinkConfiguration([
Expand Down

0 comments on commit 3f6743f

Please sign in to comment.