From 471a83bc74270269e64d5e44096f2617a59f7076 Mon Sep 17 00:00:00 2001
From: Oliver Hader <oliver@typo3.org>
Date: Thu, 14 Mar 2024 18:36:27 +0100
Subject: [PATCH] [BUGFIX] Avoid mapping route values that are out of scope
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Given a static route argument mapper is used - for instance one
that allows values in the range of 1 to 100 - then generating an
URL from a route with an out of scope `&value=5000` was still
generating an URL. However, HTTP request to that URL would
result in an 404 error.

This change skips routes using out of scope values during the
URL generation process to avoid pointing to invalid resources.

Resolves: #103400
Releases: main, 12.4, 11.5
Change-Id: I11e12f29e294ec86bec948d3a922d4a56b231771
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/83470
Reviewed-by: Stefan Bürk <stefan@buerk.tech>
Tested-by: Benni Mack <benni@typo3.org>
Tested-by: Markus Klein <markus.klein@typo3.org>
Tested-by: core-ci <typo3@b13.com>
Tested-by: Oliver Klee <typo3-coding@oliverklee.de>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Tested-by: Stefan Bürk <stefan@buerk.tech>
Reviewed-by: Markus Klein <markus.klein@typo3.org>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Benni Mack <benni@typo3.org>
---
 .../core/Classes/Routing/PageRouter.php       |  5 +-
 .../EnhancerLinkGenerator/RouteTest.php       | 65 +++++++++++++++++++
 .../Framework/Builder/VariableValue.php       | 49 ++++++++++++--
 3 files changed, 113 insertions(+), 6 deletions(-)

diff --git a/typo3/sysext/core/Classes/Routing/PageRouter.php b/typo3/sysext/core/Classes/Routing/PageRouter.php
index 18ec33dbc192..d80ce5d06139 100644
--- a/typo3/sysext/core/Classes/Routing/PageRouter.php
+++ b/typo3/sysext/core/Classes/Routing/PageRouter.php
@@ -346,7 +346,10 @@ class PageRouter implements RouterInterface
                 if ($route->hasOption('deflatedParameters')) {
                     $parameters = $route->getOption('deflatedParameters');
                 }
-                $mappableProcessor->generate($route, $parameters);
+                // skip the route, in case any aspect of it could not be mapped to a value
+                if ($mappableProcessor->generate($route, $parameters) === false) {
+                    continue;
+                }
                 // ABSOLUTE_URL is used as default fallback
                 $urlAsString = $generator->generate($routeName, $parameters, $referenceType);
                 $uri = new Uri($urlAsString);
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/EnhancerLinkGenerator/RouteTest.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/EnhancerLinkGenerator/RouteTest.php
index dc1c080f1888..504d67050cfe 100644
--- a/typo3/sysext/frontend/Tests/Functional/SiteHandling/EnhancerLinkGenerator/RouteTest.php
+++ b/typo3/sysext/frontend/Tests/Functional/SiteHandling/EnhancerLinkGenerator/RouteTest.php
@@ -453,4 +453,69 @@ final class RouteTest extends AbstractEnhancerLinkGeneratorTestCase
     {
         $this->assertGeneratedUriEquals($testSet);
     }
+
+    public static function outOfScopeValueIsNotMappedDataProvider(): array
+    {
+        // variables (applied when invoking expectations)
+        $variables = Variables::create()->define([
+            'value' => null, // defined via VariableContext
+            'routePrefix' => 'enhance',
+            'aspectName' => 'value',
+        ]);
+        $variableContexts = [];
+        $enhancers = Builder::create()->declareEnhancers();
+        foreach ($enhancers as $enhancer) {
+            $generatedParams = match ($enhancer->describe()) {
+                'Simple' => '&value=[[value]]',
+                'Plugin' => '&testing[value]=[[value]]',
+                'Extbase' => '&tx_testing_link[action]=index&tx_testing_link[controller]=Link&tx_testing_link[value]=[[value]]',
+                default => null,
+            };
+            if ($generatedParams === null) {
+                continue;
+            }
+            $variableContexts[] = VariablesContext::create(
+                Variables::create([
+                    'generatedParams' => VariableValue::createUrlEncodedParams($generatedParams, null, '?'),
+                ])
+            )->withRequiredApplicables($enhancer);
+        }
+        return Permutation::create($variables)
+            ->withTargets(
+                TestSet::create()
+                    ->withMergedApplicables(LanguageContext::create(0))
+                    ->withTargetPageId(1100)
+                    ->withUrl(
+                        VariableValue::create('https://acme.us/welcome[[generatedParams]]&cHash=')
+                    ),
+            )
+            ->withApplicableSet(
+                // value `5000` is out of scope for the given range `[1; 100]`
+                VariablesContext::create(Variables::create(['value' => 5000])),
+            )
+            ->withApplicableItems($enhancers)
+            ->withApplicableItems($variableContexts)
+            ->withApplicableSet(
+                AspectDeclaration::create('StaticRangeMapper')->withConfiguration([
+                    VariableItem::create('aspectName', [
+                        'type' => 'StaticRangeMapper',
+                        'start' => '1',
+                        'end' => '100',
+                    ]),
+                ])
+            )
+            ->permute()
+            ->getTargetsForDataProvider();
+    }
+
+    /**
+     * Asserts that value `5000` cannot generate a valid URL, having
+     * a `StaticRangeMapper` which only allows values in range `[1; 100]`.
+     */
+    #[DataProvider('outOfScopeValueIsNotMappedDataProvider')]
+    #[Test]
+    public function outOfScopeValueIsNotMapped(TestSet $testSet): void
+    {
+        $this->assertGeneratedUriEquals($testSet, false);
+    }
 }
diff --git a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Framework/Builder/VariableValue.php b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Framework/Builder/VariableValue.php
index 8298de237450..843204e2e092 100644
--- a/typo3/sysext/frontend/Tests/Functional/SiteHandling/Framework/Builder/VariableValue.php
+++ b/typo3/sysext/frontend/Tests/Functional/SiteHandling/Framework/Builder/VariableValue.php
@@ -40,10 +40,19 @@ class VariableValue
         return new static($value, $defaultVariables);
     }
 
+    public static function createUrlEncodedParams(
+        string $value,
+        Variables $defaultVariables = null,
+        string $prefix = '&'
+    ): self {
+        $value = self::urlEncodeParams($value, $prefix);
+        return self::create($value, $defaultVariables);
+    }
+
     private function __construct(string $value, Variables $defaultVariables = null)
     {
-        $variableNames = $this->extractVariableNames($value);
-        if (count($variableNames) === 0) {
+        $variableNames = self::extractVariableNames($value);
+        if ($variableNames === []) {
             throw new \LogicException(
                 sprintf(
                     'Payload did not contain any variables "%s"',
@@ -58,9 +67,15 @@ class VariableValue
         $this->defaultVariables = $defaultVariables;
     }
 
+    public function __toString(): string
+    {
+        return $this->value;
+    }
+
     public function apply(Variables $variables): string
     {
         $variables = $variables->withDefined($this->defaultVariables);
+        $variables = $this->resolveNestedVariables($variables);
 
         $this->assertVariableNames($variables);
         if (!$this->hasAllRequiredDefinedVariableNames($variables)) {
@@ -68,12 +83,25 @@ class VariableValue
         }
 
         return str_replace(
-            array_map([$this, 'wrap'], $variables->keys()),
+            array_map([self::class, 'wrap'], $variables->keys()),
             $variables->values(),
             $this->value
         );
     }
 
+    private function resolveNestedVariables(Variables $variables): Variables
+    {
+        $target = clone $variables;
+        foreach ($variables as $key => $value) {
+            if ($value instanceof self) {
+                $otherVariables = clone $variables;
+                unset($otherVariables[$key]);
+                $target[$key] = $value->apply($otherVariables);
+            }
+        }
+        return $target;
+    }
+
     private function assertVariableNames(Variables $variables): void
     {
         $missingVariableNames = array_diff($this->variableNames, $variables->keys());
@@ -89,7 +117,7 @@ class VariableValue
         }
     }
 
-    private function extractVariableNames(string $value): array
+    private static function extractVariableNames(string $value): array
     {
         if (!preg_match_all('#\[\[(?P<variableName>[^\[\]]+)\]\]#', $value, $matches)) {
             return [];
@@ -97,8 +125,19 @@ class VariableValue
         return array_unique($matches['variableName']);
     }
 
-    private function wrap(string $item): string
+    private static function wrap(string $item): string
     {
         return '[[' . $item . ']]';
     }
+
+    private static function urlEncodeParams(string $value, string $prefix = '&'): string
+    {
+        $variableNames = self::extractVariableNames($value);
+        $variableItems = array_map([self::class, 'wrap'], $variableNames);
+        $substitutes = array_map(static fn(): string => bin2hex(random_bytes(20)), $variableNames);
+        $value = str_replace($variableItems, $substitutes, $value);
+        parse_str($value, $params);
+        $value = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
+        return $prefix . str_replace($substitutes, $variableItems, $value);
+    }
 }
-- 
GitLab