From 18e3f4f7db2c2453579082c84c95ee8b2105e41b Mon Sep 17 00:00:00 2001
From: Oliver Hader <oliver@typo3.org>
Date: Sat, 16 May 2020 12:05:11 +0200
Subject: [PATCH] [BUGFIX] Allow arbitrary objects in widget context
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

TYPO3-CORE-SA-2020-005 caused side-effects on Fluid AJAX widgets which
unfortunatelly support any class instance to be temporarily stored in
the current user-session. With mentioned change to address an insecure
deserialization vulnerability it was limited to items that could be
JSON-serialized.

This limitation is removed again by switching back to `unserialize()`,
but using an encryption-key-based HMAC signature on the payload.
Due to its architecture there is no better approach available.

This partially reverts commit e4fb92a85bed8067d45d2c25c4b559642f206248.

Resolves: #91382
Releases: master, 9.5
Change-Id: I68cbd15e7df2f536180f174fa63cf27f8a19cfcd
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/64501
Tested-by: TYPO3com <noreply@typo3.com>
Tested-by: Jonas Götze <jonnsn@gmail.com>
Tested-by: Alexander Schnitzler <git@alexanderschnitzler.de>
Tested-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Tested-by: Susanne Moog <look@susi.dev>
Reviewed-by: Alexander Schnitzler <git@alexanderschnitzler.de>
Reviewed-by: Andreas Fernandez <a.fernandez@scripting-base.de>
Reviewed-by: Susanne Moog <look@susi.dev>
---
 .../Core/Widget/AjaxWidgetContextHolder.php   | 50 ++++++++------
 .../Classes/Core/Widget/WidgetContext.php     | 69 +++----------------
 .../Unit/Core/Widget/WidgetContextTest.php    |  4 +-
 3 files changed, 42 insertions(+), 81 deletions(-)

diff --git a/typo3/sysext/fluid/Classes/Core/Widget/AjaxWidgetContextHolder.php b/typo3/sysext/fluid/Classes/Core/Widget/AjaxWidgetContextHolder.php
index 24edbcbdd36c..3476e580e499 100644
--- a/typo3/sysext/fluid/Classes/Core/Widget/AjaxWidgetContextHolder.php
+++ b/typo3/sysext/fluid/Classes/Core/Widget/AjaxWidgetContextHolder.php
@@ -16,6 +16,8 @@
 namespace TYPO3\CMS\Fluid\Core\Widget;
 
 use TYPO3\CMS\Core\SingletonInterface;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Extbase\Security\Cryptography\HashService;
 use TYPO3\CMS\Fluid\Core\Widget\Exception\WidgetContextNotFoundException;
 use TYPO3\CMS\Frontend\Controller\TypoScriptFrontendController;
 
@@ -41,11 +43,17 @@ class AjaxWidgetContextHolder implements SingletonInterface
      */
     protected $widgetContextsStorageKey = 'TYPO3\\CMS\\Fluid\\Core\\Widget\\AjaxWidgetContextHolder_widgetContexts';
 
+    /**
+     * @var HashService
+     */
+    protected $hashService;
+
     /**
      * Constructor
      */
     public function __construct()
     {
+        $this->hashService = GeneralUtility::makeInstance(HashService::class);
         $this->loadWidgetContexts();
     }
 
@@ -55,13 +63,9 @@ class AjaxWidgetContextHolder implements SingletonInterface
     protected function loadWidgetContexts()
     {
         if (isset($GLOBALS['TSFE']) && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController) {
-            $this->widgetContexts = $this->buildWidgetContextsFromArray(
-                json_decode($GLOBALS['TSFE']->fe_user->getKey('ses', $this->widgetContextsStorageKey ?? null), true) ?? []
-            );
+            $this->widgetContexts = $this->unserializeWithHmac($GLOBALS['TSFE']->fe_user->getKey('ses', $this->widgetContextsStorageKey));
         } else {
-            $this->widgetContexts = $this->buildWidgetContextsFromArray(
-                json_decode($GLOBALS['BE_USER']->uc[$this->widgetContextsStorageKey] ?? '', true) ?? []
-            );
+            $this->widgetContexts = isset($GLOBALS['BE_USER']->uc[$this->widgetContextsStorageKey]) ? $this->unserializeWithHmac($GLOBALS['BE_USER']->uc[$this->widgetContextsStorageKey]) : [];
             $GLOBALS['BE_USER']->writeUC();
         }
     }
@@ -100,27 +104,33 @@ class AjaxWidgetContextHolder implements SingletonInterface
     protected function storeWidgetContexts()
     {
         if (isset($GLOBALS['TSFE']) && $GLOBALS['TSFE'] instanceof TypoScriptFrontendController) {
-            $GLOBALS['TSFE']->fe_user->setKey('ses', $this->widgetContextsStorageKey, json_encode($this->widgetContexts));
+            $GLOBALS['TSFE']->fe_user->setKey('ses', $this->widgetContextsStorageKey, $this->serializeWithHmac($this->widgetContexts));
             $GLOBALS['TSFE']->fe_user->storeSessionData();
         } else {
-            $GLOBALS['BE_USER']->uc[$this->widgetContextsStorageKey] = json_encode($this->widgetContexts);
+            $GLOBALS['BE_USER']->uc[$this->widgetContextsStorageKey] = $this->serializeWithHmac($this->widgetContexts);
             $GLOBALS['BE_USER']->writeUC();
         }
     }
 
-    /**
-     * Builds WidgetContext instances from JSON representation,
-     * this is basically required for AJAX widgets only.
-     *
-     * @param array $data
-     * @return WidgetContext[]
-     */
-    protected function buildWidgetContextsFromArray(array $data): array
+    protected function serializeWithHmac(array $widgetContexts): string
     {
-        $widgetContexts = [];
-        foreach ($data as $widgetId => $widgetContextData) {
-            $widgetContexts[$widgetId] = WidgetContext::fromArray($widgetContextData);
+        $data = serialize($widgetContexts);
+        return $this->hashService->appendHmac($data);
+    }
+
+    protected function unserializeWithHmac(?string $data): array
+    {
+        if ($data === null || $data === '') {
+            return [];
+        }
+        try {
+            $data = $this->hashService->validateAndStripHmac($data);
+            // widget contexts literally can contain everything, that why using
+            // HMAC-signed `unserialize()` is the only option here unless Extbase
+            // structures have been refactored further
+            $widgetContexts = unserialize($data);
+        } finally {
+            return is_array($widgetContexts ?? null) ? $widgetContexts : [];
         }
-        return $widgetContexts;
     }
 }
diff --git a/typo3/sysext/fluid/Classes/Core/Widget/WidgetContext.php b/typo3/sysext/fluid/Classes/Core/Widget/WidgetContext.php
index 5f2035182ade..0049f86a4a58 100644
--- a/typo3/sysext/fluid/Classes/Core/Widget/WidgetContext.php
+++ b/typo3/sysext/fluid/Classes/Core/Widget/WidgetContext.php
@@ -29,19 +29,8 @@ use TYPO3Fluid\Fluid\Core\Rendering\RenderingContextInterface;
  *
  * @internal It is a purely internal class which should not be used outside of Fluid.
  */
-class WidgetContext implements \JsonSerializable
+class WidgetContext
 {
-    protected const JSON_SERIALIZABLE_PROPERTIES = [
-        'widgetIdentifier',
-        'ajaxWidgetIdentifier',
-        'widgetConfiguration',
-        'controllerObjectName',
-        'parentPluginNamespace',
-        'parentExtensionName',
-        'parentPluginName',
-        'widgetViewHelperClassName',
-    ];
-
     /**
      * Uniquely identifies a Widget Instance on a certain page.
      *
@@ -58,8 +47,7 @@ class WidgetContext implements \JsonSerializable
 
     /**
      * User-supplied widget configuration, available inside the widget
-     * controller as $this->widgetConfiguration. Array items must be
-     * null, scalar, array or implement \JsonSerializable interface.
+     * controller as $this->widgetConfiguration.
      *
      * @var array
      */
@@ -109,51 +97,6 @@ class WidgetContext implements \JsonSerializable
      */
     protected $widgetViewHelperClassName;
 
-    public static function fromArray(array $data): WidgetContext
-    {
-        $target = new WidgetContext();
-        foreach ($data as $propertyName => $propertyValue) {
-            if (!in_array($propertyName, self::JSON_SERIALIZABLE_PROPERTIES, true)) {
-                continue;
-            }
-            if (!property_exists($target, $propertyName)) {
-                continue;
-            }
-            $target->{$propertyName} = $propertyValue;
-        }
-        return $target;
-    }
-
-    public function jsonSerialize()
-    {
-        $properties = array_fill_keys(self::JSON_SERIALIZABLE_PROPERTIES, true);
-        $data = array_intersect_key(get_object_vars($this), $properties);
-
-        if (is_array($data['widgetConfiguration'] ?? null)) {
-            $data['widgetConfiguration'] = array_filter(
-                $data['widgetConfiguration'],
-                function ($value): bool {
-                    if ($value === null || is_scalar($value) || is_array($value)
-                        || is_object($value) && $value instanceof \JsonSerializable
-                    ) {
-                        return true;
-                    }
-                    throw new Exception(
-                        sprintf(
-                            'Widget configuration items either must be null, scalar, array or JSON serializable, got "%s"',
-                            is_object($value) ? get_class($value) : gettype($value)
-                        ),
-                        1588178538
-                    );
-                }
-            );
-        } else {
-            $data['widgetConfiguration'] = null;
-        }
-
-        return $data;
-    }
-
     /**
      * @return string
      */
@@ -323,4 +266,12 @@ class WidgetContext implements \JsonSerializable
     {
         return $this->viewHelperChildNodeRenderingContext;
     }
+
+    /**
+     * @return array
+     */
+    public function __sleep()
+    {
+        return ['widgetIdentifier', 'ajaxWidgetIdentifier', 'widgetConfiguration', 'controllerObjectName', 'parentPluginNamespace', 'parentExtensionName', 'parentPluginName', 'widgetViewHelperClassName'];
+    }
 }
diff --git a/typo3/sysext/fluid/Tests/Unit/Core/Widget/WidgetContextTest.php b/typo3/sysext/fluid/Tests/Unit/Core/Widget/WidgetContextTest.php
index 14035d27ede4..1adc67137ad6 100644
--- a/typo3/sysext/fluid/Tests/Unit/Core/Widget/WidgetContextTest.php
+++ b/typo3/sysext/fluid/Tests/Unit/Core/Widget/WidgetContextTest.php
@@ -129,14 +129,14 @@ class WidgetContextTest extends UnitTestCase
     /**
      * @test
      */
-    public function jsonEncodeContainsExpectedPropertyNames()
+    public function sleepReturnsExpectedPropertyNames()
     {
         self::assertEquals(
             [
                 'widgetIdentifier', 'ajaxWidgetIdentifier', 'widgetConfiguration', 'controllerObjectName',
                 'parentPluginNamespace', 'parentExtensionName', 'parentPluginName', 'widgetViewHelperClassName'
             ],
-            array_keys(json_decode(json_encode($this->widgetContext), true))
+            $this->widgetContext->__sleep()
         );
     }
 }
-- 
GitLab