[SECURITY] Introduce PHP stream wrapper for phar:// protocol 58/57558/2
authorOliver Hader <oliver@typo3.org>
Thu, 12 Jul 2018 09:35:24 +0000 (11:35 +0200)
committerOliver Hader <oliver.hader@typo3.org>
Thu, 12 Jul 2018 09:35:29 +0000 (11:35 +0200)
This custom stream wrapper for the phar:// protocol overrides
PHP's native handling. In case Phar bundles shall be loaded from
a valid directory, the custom wrapper falls back to the native PHP
wrapper in order to invoke Phar-related actions.

In case the location is not trustworthy, an according exception
is thrown. The custom stream wrapper is registered in the beginning
of TYPO3's bootstrap class.

Truested locations are those in typo3conf/ext/* - anything else is
denied and not considered as trustworthy.

Releases: master, 8.7, 7.6
Resolves: #85385
Security-Commit: efa085d9a5aebfac6b92309ea53c455b95a81fcc
Security-Bulletin: TYPO3-CORE-SA-2018-002
Change-Id: Ifd38eab7a5757e6cfbd6f773a3fed8f3d742e09d
Reviewed-on: https://review.typo3.org/57558
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>
Tested-by: Oliver Hader <oliver.hader@typo3.org>
typo3/sysext/core/Classes/Core/Bootstrap.php
typo3/sysext/core/Classes/IO/PharStreamWrapper.php [new file with mode: 0644]
typo3/sysext/core/Classes/IO/PharStreamWrapperException.php [new file with mode: 0644]
typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_resources/bundle.phar [new file with mode: 0644]
typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_resources/ext_emconf.php [new file with mode: 0644]
typo3/sysext/core/Tests/Functional/IO/PharStreamWrapperTest.php [new file with mode: 0644]

index 80a62ff..8823933 100644 (file)
@@ -22,6 +22,7 @@ use Psr\Container\NotFoundExceptionInterface;
 use TYPO3\CMS\Core\Cache\CacheManager;
 use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
 use TYPO3\CMS\Core\Configuration\ConfigurationManager;
+use TYPO3\CMS\Core\IO\PharStreamWrapper;
 use TYPO3\CMS\Core\Localization\Locales;
 use TYPO3\CMS\Core\Log\LogManager;
 use TYPO3\CMS\Core\Package\FailsafePackageManager;
@@ -87,6 +88,7 @@ class Bootstrap
         }
         static::populateLocalConfiguration($configurationManager);
         static::initializeErrorHandling();
+        static::initializeIO();
 
         $logManager = new LogManager($requestId);
         $cacheManager = static::createCacheManager($failsafe ? true : false);
@@ -679,6 +681,17 @@ class Bootstrap
     }
 
     /**
+     * Initializes IO and stream wrapper related behavior.
+     */
+    protected static function initializeIO()
+    {
+        if (in_array('phar', stream_get_wrappers())) {
+            stream_wrapper_unregister('phar');
+            stream_wrapper_register('phar', PharStreamWrapper::class);
+        }
+    }
+
+    /**
      * Set PHP memory limit depending on value of
      * $GLOBALS['TYPO3_CONF_VARS']['SYS']['setMemoryLimit']
      */
diff --git a/typo3/sysext/core/Classes/IO/PharStreamWrapper.php b/typo3/sysext/core/Classes/IO/PharStreamWrapper.php
new file mode 100644 (file)
index 0000000..0b53bdb
--- /dev/null
@@ -0,0 +1,557 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\IO;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\Core\Environment;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
+use TYPO3\CMS\Core\Utility\PathUtility;
+
+class PharStreamWrapper
+{
+    /**
+     * Internal stream constants that are not exposed to PHP, but used...
+     * @see https://github.com/php/php-src/blob/e17fc0d73c611ad0207cac8a4a01ded38251a7dc/main/php_streams.h
+     */
+    protected const STREAM_OPEN_FOR_INCLUDE = 128;
+
+    /**
+     * @var resource
+     */
+    public $context;
+
+    /**
+     * @var resource
+     */
+    protected $internalResource;
+
+    /**
+     * @return bool
+     */
+    public function dir_closedir(): bool
+    {
+        if (!is_resource($this->internalResource)) {
+            return false;
+        }
+
+        $this->invokeInternalStreamWrapper(
+            'closedir',
+            $this->internalResource
+        );
+        return !is_resource($this->internalResource);
+    }
+
+    /**
+     * @param string $path
+     * @param int $options
+     * @return bool
+     */
+    public function dir_opendir(string $path, int $options): bool
+    {
+        $this->assertPath($path);
+        $this->internalResource = $this->invokeInternalStreamWrapper(
+            'opendir',
+            $path,
+            $this->context
+        );
+        return is_resource($this->internalResource);
+    }
+
+    /**
+     * @return string|false
+     */
+    public function dir_readdir()
+    {
+        return $this->invokeInternalStreamWrapper(
+            'readdir',
+            $this->internalResource
+        );
+    }
+
+    /**
+     * @return bool
+     */
+    public function dir_rewinddir(): bool
+    {
+        if (!is_resource($this->internalResource)) {
+            return false;
+        }
+
+        $this->invokeInternalStreamWrapper(
+            'rewinddir',
+            $this->internalResource
+        );
+        return is_resource($this->internalResource);
+    }
+
+    /**
+     * @param string $path
+     * @param int $mode
+     * @param int $options
+     * @return bool
+     */
+    public function mkdir(string $path, int $mode, int $options): bool
+    {
+        $this->assertPath($path);
+        return $this->invokeInternalStreamWrapper(
+            'mkdir',
+            $path,
+            $mode,
+            (bool)($options & STREAM_MKDIR_RECURSIVE),
+            $this->context
+        );
+    }
+
+    /**
+     * @param string $path_from
+     * @param string $path_to
+     * @return bool
+     */
+    public function rename(string $path_from, string $path_to): bool
+    {
+        $this->assertPath($path_from);
+        $this->assertPath($path_to);
+        return $this->invokeInternalStreamWrapper(
+            'rename',
+            $path_from,
+            $path_to,
+            $this->context
+        );
+    }
+
+    public function rmdir(string $path, int $options): bool
+    {
+        $this->assertPath($path);
+        return $this->invokeInternalStreamWrapper(
+            'rmdir',
+            $path,
+            $this->context
+        );
+    }
+
+    /**
+     * @param int $cast_as
+     */
+    public function stream_cast(int $cast_as)
+    {
+        throw new PharStreamWrapperException(
+            'Method stream_select() cannot be used',
+            1530103999
+        );
+    }
+
+    public function stream_close()
+    {
+        $this->invokeInternalStreamWrapper(
+            'fclose',
+            $this->internalResource
+        );
+    }
+
+    /**
+     * @return bool
+     */
+    public function stream_eof(): bool
+    {
+        return $this->invokeInternalStreamWrapper(
+            'feof',
+            $this->internalResource
+        );
+    }
+
+    /**
+     * @return bool
+     */
+    public function stream_flush(): bool
+    {
+        return $this->invokeInternalStreamWrapper(
+            'fflush',
+            $this->internalResource
+        );
+    }
+
+    /**
+     * @param int $operation
+     * @return bool
+     */
+    public function stream_lock(int $operation): bool
+    {
+        return $this->invokeInternalStreamWrapper(
+            'flock',
+            $this->internalResource,
+            $operation
+        );
+    }
+
+    /**
+     * @param string $path
+     * @param int $option
+     * @param string|int $value
+     * @return bool
+     */
+    public function stream_metadata(string $path, int $option, $value): bool
+    {
+        $this->assertPath($path);
+        if ($option === STREAM_META_TOUCH) {
+            return $this->invokeInternalStreamWrapper(
+                'touch',
+                $path,
+                ...$value
+            );
+        }
+        if ($option === STREAM_META_OWNER_NAME || $option === STREAM_META_OWNER) {
+            return $this->invokeInternalStreamWrapper(
+                'chown',
+                $path,
+                $value
+            );
+        }
+        if ($option === STREAM_META_GROUP_NAME || $option === STREAM_META_GROUP) {
+            return $this->invokeInternalStreamWrapper(
+                'chgrp',
+                $path,
+                $value
+            );
+        }
+        if ($option === STREAM_META_ACCESS) {
+            return $this->invokeInternalStreamWrapper(
+                'chmod',
+                $path,
+                $value
+            );
+        }
+        return false;
+    }
+
+    /**
+     * @param string $path
+     * @param string $mode
+     * @param int $options
+     * @param string|null $opened_path
+     * @return bool
+     */
+    public function stream_open(
+        string $path,
+        string $mode,
+        int $options,
+        string &$opened_path = null
+    ): bool {
+        $this->assertPath($path);
+        $arguments = [$path, $mode, (bool)($options & STREAM_USE_PATH)];
+        // only add stream context for non include/require calls
+        if (!($options & static::STREAM_OPEN_FOR_INCLUDE)) {
+            $arguments[] = $this->context;
+        // work around https://bugs.php.net/bug.php?id=66569
+        // for including files from Phar stream with OPcache enabled
+        } else {
+            $this->resetOpCache();
+        }
+        $this->internalResource = $this->invokeInternalStreamWrapper(
+            'fopen',
+            ...$arguments
+        );
+        if (!is_resource($this->internalResource)) {
+            return false;
+        }
+        if ($opened_path !== null) {
+            $metaData = stream_get_meta_data($this->internalResource);
+            $opened_path = $metaData['uri'];
+        }
+        return true;
+    }
+
+    /**
+     * @param int $count
+     * @return string
+     */
+    public function stream_read(int $count): string
+    {
+        return $this->invokeInternalStreamWrapper(
+            'fread',
+            $this->internalResource,
+            $count
+        );
+    }
+
+    /**
+     * @param int $offset
+     * @param int $whence
+     * @return bool
+     */
+    public function stream_seek(int $offset, int $whence = SEEK_SET): bool
+    {
+        return $this->invokeInternalStreamWrapper(
+            'fseek',
+            $this->internalResource,
+            $offset,
+            $whence
+        ) !== -1;
+    }
+
+    /**
+     * @param int $option
+     * @param int $arg1
+     * @param int $arg2
+     * @return bool
+     */
+    public function stream_set_option(int $option, int $arg1, int $arg2): bool
+    {
+        if ($option === STREAM_OPTION_BLOCKING) {
+            return $this->invokeInternalStreamWrapper(
+                'stream_set_blocking',
+                $this->internalResource,
+                $arg1
+            );
+        }
+        if ($option === STREAM_OPTION_READ_TIMEOUT) {
+            return $this->invokeInternalStreamWrapper(
+                'stream_set_timeout',
+                $this->internalResource,
+                $arg1,
+                $arg2
+            );
+        }
+        if ($option === STREAM_OPTION_WRITE_BUFFER) {
+            return $this->invokeInternalStreamWrapper(
+                'stream_set_write_buffer',
+                $this->internalResource,
+                $arg2
+            ) === 0;
+        }
+        return false;
+    }
+
+    /**
+     * @return array
+     */
+    public function stream_stat(): array
+    {
+        return $this->invokeInternalStreamWrapper(
+            'fstat',
+            $this->internalResource
+        );
+    }
+
+    /**
+     * @return int
+     */
+    public function stream_tell(): int
+    {
+        return $this->invokeInternalStreamWrapper(
+            'ftell',
+            $this->internalResource
+        );
+    }
+
+    /**
+     * @param int $new_size
+     * @return bool
+     */
+    public function stream_truncate(int $new_size): bool
+    {
+        return $this->invokeInternalStreamWrapper(
+            'ftruncate',
+            $this->internalResource,
+            $new_size
+        );
+    }
+
+    /**
+     * @param string $data
+     * @return int
+     */
+    public function stream_write(string $data): int
+    {
+        return $this->invokeInternalStreamWrapper(
+            'fwrite',
+            $this->internalResource,
+            $data
+        );
+    }
+
+    /**
+     * @param string $path
+     * @return bool
+     */
+    public function unlink(string $path): bool
+    {
+        $this->assertPath($path);
+        return $this->invokeInternalStreamWrapper(
+            'unlink',
+            $path,
+            $this->context
+        );
+    }
+
+    /**
+     * @param string $path
+     * @param int $flags
+     * @return array|false
+     */
+    public function url_stat(string $path, int $flags)
+    {
+        $this->assertPath($path);
+        $functionName = $flags & STREAM_URL_STAT_QUIET ? '@stat' : 'stat';
+        return $this->invokeInternalStreamWrapper($functionName, $path);
+    }
+
+    /**
+     * @param string $path
+     * @return bool
+     */
+    protected function isAllowed(string $path): bool
+    {
+        $path = $this->determineBaseFile($path);
+        if (!GeneralUtility::isAbsPath($path)) {
+            $path = Environment::getPublicPath() . '/' . $path;
+        }
+
+        if (GeneralUtility::validPathStr($path)
+            && GeneralUtility::isFirstPartOfStr(
+                $path,
+                Environment::getPublicPath() . '/typo3conf/ext/'
+            )
+        ) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Normalizes a path, removes phar:// prefix, fixes Windows directory
+     * separators. Result is without trailing slash.
+     *
+     * @param string $path
+     * @return string
+     */
+    protected function normalizePath(string $path): string
+    {
+        return rtrim(
+            PathUtility::getCanonicalPath(
+                GeneralUtility::fixWindowsFilePath(
+                    $this->removePharPrefix($path)
+                )
+            ),
+            '/'
+        );
+    }
+
+    /**
+     * @param string $path
+     * @return string
+     */
+    protected function removePharPrefix(string $path): string
+    {
+        return preg_replace('#^phar://#i', '', $path);
+    }
+
+    /**
+     * Determines base file that can be accessed using the regular file system.
+     * For e.g. "phar:///home/user/bundle.phar/content.txt" that would result
+     * into "/home/user/bundle.phar".
+     *
+     * @param string $path
+     * @return string|null
+     */
+    protected function determineBaseFile(string $path)
+    {
+        $parts = explode('/', $this->normalizePath($path));
+
+        while (count($parts)) {
+            $currentPath = implode('/', $parts);
+            if (file_exists($currentPath)) {
+                return $currentPath;
+            }
+            array_pop($parts);
+        }
+
+        return null;
+    }
+
+    /**
+     * Determines whether the requested path is the base file.
+     *
+     * @param string $path
+     * @return bool
+     * @deprecated Currently not used
+     */
+    protected function isBaseFile(string $path): bool
+    {
+        $path = $this->normalizePath($path);
+        $baseFile = $this->determineBaseFile($path);
+        return $path === $baseFile;
+    }
+
+    /**
+     * Asserts the given path to a Phar file.
+     *
+     * @param string $path
+     * @throws PharStreamWrapperException
+     */
+    protected function assertPath(string $path)
+    {
+        if (!$this->isAllowed($path)) {
+            throw new PharStreamWrapperException(
+                sprintf('Executing  %s is denied', $path),
+                1530103998
+            );
+        }
+    }
+
+    protected function resetOpCache()
+    {
+        if (function_exists('opcache_reset')
+            && function_exists('opcache_get_status')
+            && !empty(opcache_get_status()['opcache_enabled'])
+        ) {
+            opcache_reset();
+        }
+    }
+
+    /**
+     * Invokes commands on the native PHP Phar stream wrapper.
+     *
+     * @param string $functionName
+     * @param mixed ...$arguments
+     * @return mixed
+     */
+    protected function invokeInternalStreamWrapper(string $functionName, ...$arguments)
+    {
+        $silentExecution = $functionName{0} === '@';
+        $functionName = ltrim($functionName, '@');
+        $this->restoreInternalSteamWrapper();
+
+        if ($silentExecution) {
+            $result = @call_user_func_array($functionName, $arguments);
+        } else {
+            $result = call_user_func_array($functionName, $arguments);
+        }
+
+        $this->registerStreamWrapper();
+        return $result;
+    }
+
+    protected function restoreInternalSteamWrapper()
+    {
+        stream_wrapper_restore('phar');
+    }
+
+    protected function registerStreamWrapper()
+    {
+        stream_wrapper_unregister('phar');
+        stream_wrapper_register('phar', static::class);
+    }
+}
diff --git a/typo3/sysext/core/Classes/IO/PharStreamWrapperException.php b/typo3/sysext/core/Classes/IO/PharStreamWrapperException.php
new file mode 100644 (file)
index 0000000..57126c1
--- /dev/null
@@ -0,0 +1,20 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\IO;
+
+/*
+ * 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!
+ */
+
+class PharStreamWrapperException extends \RuntimeException
+{
+}
diff --git a/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_resources/bundle.phar b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_resources/bundle.phar
new file mode 100644 (file)
index 0000000..c829673
Binary files /dev/null and b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_resources/bundle.phar differ
diff --git a/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_resources/ext_emconf.php b/typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_resources/ext_emconf.php
new file mode 100644 (file)
index 0000000..08b3e19
--- /dev/null
@@ -0,0 +1,21 @@
+<?php
+$EM_CONF[$_EXTKEY] = [
+    'title' => 'Test Resources',
+    'description' => 'Test Resources',
+    'category' => 'example',
+    'version' => '9.4.0',
+    'state' => 'beta',
+    'uploadfolder' => 0,
+    'createDirs' => '',
+    'clearCacheOnLoad' => 0,
+    'author' => 'Oliver Hader',
+    'author_email' => 'oliver@typo3.org',
+    'author_company' => '',
+    'constraints' => [
+        'depends' => [
+            'typo3' => '9.4.0'
+        ],
+        'conflicts' => [],
+        'suggests' => [],
+    ],
+];
diff --git a/typo3/sysext/core/Tests/Functional/IO/PharStreamWrapperTest.php b/typo3/sysext/core/Tests/Functional/IO/PharStreamWrapperTest.php
new file mode 100644 (file)
index 0000000..c886695
--- /dev/null
@@ -0,0 +1,402 @@
+<?php
+declare(strict_types = 1);
+namespace TYPO3\CMS\Core\Tests\Functional\IO;
+
+/*
+ * 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!
+ */
+
+use TYPO3\CMS\Core\IO\PharStreamWrapper;
+use TYPO3\CMS\Core\IO\PharStreamWrapperException;
+use TYPO3\TestingFramework\Core\Functional\FunctionalTestCase;
+
+class PharStreamWrapperTest extends FunctionalTestCase
+{
+    /**
+     * @var array
+     */
+    protected $testExtensionsToLoad = [
+        'typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_resources'
+    ];
+
+    /**
+     * @var array
+     */
+    protected $pathsToLinkInTestInstance = [
+        'typo3/sysext/core/Tests/Functional/Fixtures/Extensions/test_resources/bundle.phar' => 'fileadmin/bundle.phar'
+    ];
+
+    protected function setUp()
+    {
+        parent::setUp();
+
+        if (!in_array('phar', stream_get_wrappers())) {
+            $this->markTestSkipped('Phar stream wrapper is not registered');
+        }
+
+        stream_wrapper_unregister('phar');
+        stream_wrapper_register('phar', PharStreamWrapper::class);
+    }
+
+    protected function tearDown()
+    {
+        stream_wrapper_restore('phar');
+        parent::tearDown();
+    }
+
+    public function directoryActionAllowsInvocationDataProvider()
+    {
+        $allowedPath = 'typo3conf/ext/test_resources/bundle.phar';
+
+        return [
+            'root directory' => [
+                $allowedPath,
+                ['Classes', 'Resources']
+            ],
+            'Classes/Domain/Model directory' => [
+                $allowedPath . '/Classes/Domain/Model',
+                ['DemoModel.php']
+            ],
+            'Resources directory' => [
+                $allowedPath . '/Resources',
+                ['content.txt']
+            ],
+        ];
+    }
+
+    /**
+     * @param string $path
+     *
+     * @test
+     * @dataProvider directoryActionAllowsInvocationDataProvider
+     */
+    public function directoryOpenAllowsInvocation(string $path)
+    {
+        $path = $this->instancePath . '/' . $path;
+        $handle = opendir('phar://' . $path);
+        self::assertInternalType('resource', $handle);
+    }
+
+    /**
+     * @param string $path
+     * @param $expectation
+     *
+     * @test
+     * @dataProvider directoryActionAllowsInvocationDataProvider
+     */
+    public function directoryReadAllowsInvocation(string $path, array $expectation)
+    {
+        $path = $this->instancePath . '/' . $path;
+
+        $items = [];
+        $handle = opendir('phar://' . $path);
+        while (false !== $item = readdir($handle)) {
+            $items[] = $item;
+        }
+
+        self::assertSame($expectation, $items);
+    }
+
+    /**
+     * @param string $path
+     * @param $expectation
+     *
+     * @test
+     * @dataProvider directoryActionAllowsInvocationDataProvider
+     */
+    public function directoryCloseAllowsInvocation(string $path, array $expectation)
+    {
+        $path = $this->instancePath . '/' . $path;
+
+        $handle = opendir('phar://' . $path);
+        closedir($handle);
+
+        self::assertFalse(is_resource($handle));
+    }
+
+    public function directoryActionDeniesInvocationDataProvider()
+    {
+        $deniedPath = 'fileadmin/bundle.phar';
+
+        return [
+            'root directory' => [
+                $deniedPath,
+                ['Classes', 'Resources']
+            ],
+            'Classes/Domain/Model directory' => [
+                $deniedPath . '/Classes/Domain/Model',
+                ['DemoModel.php']
+            ],
+            'Resources directory' => [
+                $deniedPath . '/Resources',
+                ['content.txt']
+            ],
+        ];
+    }
+
+    /**
+     * @param string $path
+     *
+     * @test
+     * @dataProvider directoryActionDeniesInvocationDataProvider
+     */
+    public function directoryActionDeniesInvocation(string $path)
+    {
+        self::expectException(PharStreamWrapperException::class);
+        self::expectExceptionCode(1530103998);
+
+        $path = $this->instancePath . '/' . $path;
+        opendir('phar://' . $path);
+    }
+
+    /**
+     * @return array
+     */
+    public function urlStatAllowsInvocationDataProvider(): array
+    {
+        $allowedPath = 'typo3conf/ext/test_resources/bundle.phar';
+
+        return [
+            'filesize base file' => [
+                'filesize',
+                $allowedPath,
+                0, // Phar base file always has zero size when accessed through phar://
+            ],
+            'filesize Resources/content.txt' => [
+                'filesize',
+                $allowedPath . '/Resources/content.txt',
+                21,
+            ],
+            'is_file base file' => [
+                'is_file',
+                $allowedPath,
+                false, // Phar base file is not a file when accessed through phar://
+            ],
+            'is_file Resources/content.txt' => [
+                'is_file',
+                $allowedPath . '/Resources/content.txt',
+                true,
+            ],
+            'is_dir base file' => [
+                'is_dir',
+                $allowedPath,
+                true, // Phar base file is a directory when accessed through phar://
+            ],
+            'is_dir Resources/content.txt' => [
+                'is_dir',
+                $allowedPath . '/Resources/content.txt',
+                false,
+            ],
+            'file_exists base file' => [
+                'file_exists',
+                $allowedPath,
+                true
+            ],
+            'file_exists Resources/content.txt' => [
+                'file_exists',
+                $allowedPath . '/Resources/content.txt',
+                true
+            ],
+        ];
+    }
+
+    /**
+     * @param string $functionName
+     * @param string $path
+     * @param mixed $expectation
+     *
+     * @test
+     * @dataProvider urlStatAllowsInvocationDataProvider
+     */
+    public function urlStatAllowsInvocation(string $functionName, string $path, $expectation)
+    {
+        $path = $this->instancePath . '/' . $path;
+
+        self::assertSame(
+            $expectation,
+            call_user_func($functionName, 'phar://' . $path)
+        );
+    }
+
+    /**
+     * @return array
+     */
+    public function urlStatDeniesInvocationDataProvider(): array
+    {
+        $deniedPath = 'fileadmin/bundle.phar';
+
+        return [
+            'filesize base file' => [
+                'filesize',
+                $deniedPath,
+                0, // Phar base file always has zero size when accessed through phar://
+            ],
+            'filesize Resources/content.txt' => [
+                'filesize',
+                $deniedPath . '/Resources/content.txt',
+                21,
+            ],
+            'is_file base file' => [
+                'is_file',
+                $deniedPath,
+                false, // Phar base file is not a file when accessed through phar://
+            ],
+            'is_file Resources/content.txt' => [
+                'is_file',
+                $deniedPath . '/Resources/content.txt',
+                true,
+            ],
+            'is_dir base file' => [
+                'is_dir',
+                $deniedPath,
+                true, // Phar base file is a directory when accessed through phar://
+            ],
+            'is_dir Resources/content.txt' => [
+                'is_dir',
+                $deniedPath . '/Resources/content.txt',
+                false,
+            ],
+            'file_exists base file' => [
+                'file_exists',
+                $deniedPath,
+                true
+            ],
+            'file_exists Resources/content.txt' => [
+                'file_exists',
+                $deniedPath . '/Resources/content.txt',
+                true
+            ],
+        ];
+    }
+
+    /**
+     * @param string $functionName
+     * @param string $path
+     * @param mixed $expectation
+     *
+     * @test
+     * @dataProvider urlStatDeniesInvocationDataProvider
+     */
+    public function urlStatDeniesInvocation(string $functionName, string $path)
+    {
+        self::expectException(PharStreamWrapperException::class);
+        self::expectExceptionCode(1530103998);
+
+        $path = $this->instancePath . '/' . $path;
+        call_user_func($functionName, 'phar://' . $path);
+    }
+
+    /**
+     * @test
+     */
+    public function streamOpenAllowsInvocationForFileOpen()
+    {
+        $allowedPath = $this->instancePath . '/typo3conf/ext/test_resources/bundle.phar';
+        $handle = fopen('phar://' . $allowedPath . '/Resources/content.txt', 'r');
+        self::assertInternalType('resource', $handle);
+    }
+
+    /**
+     * @test
+     */
+    public function streamOpenAllowsInvocationForFileRead()
+    {
+        $allowedPath = $this->instancePath . '/typo3conf/ext/test_resources/bundle.phar';
+        $handle = fopen('phar://' . $allowedPath . '/Resources/content.txt', 'r');
+        $content = fread($handle, 1024);
+        self::assertSame('TYPO3 demo text file.', $content);
+    }
+
+    /**
+     * @test
+     */
+    public function streamOpenAllowsInvocationForFileEnd()
+    {
+        $allowedPath = $this->instancePath . '/typo3conf/ext/test_resources/bundle.phar';
+        $handle = fopen('phar://' . $allowedPath . '/Resources/content.txt', 'r');
+        fread($handle, 1024);
+        self::assertTrue(feof($handle));
+    }
+
+    /**
+     * @test
+     */
+    public function streamOpenAllowsInvocationForFileClose()
+    {
+        $allowedPath = $this->instancePath . '/typo3conf/ext/test_resources/bundle.phar';
+        $handle = fopen('phar://' . $allowedPath . '/Resources/content.txt', 'r');
+        fclose($handle);
+        self::assertFalse(is_resource($handle));
+    }
+
+    /**
+     * @test
+     */
+    public function streamOpenAllowsInvocationForFileGetContents()
+    {
+        $allowedPath = $this->instancePath . '/typo3conf/ext/test_resources/bundle.phar';
+        $content = file_get_contents('phar://' . $allowedPath . '/Resources/content.txt');
+        self::assertSame('TYPO3 demo text file.', $content);
+    }
+
+    /**
+     * @test
+     */
+    public function streamOpenAllowsInvocationForInclude()
+    {
+        $allowedPath = $this->instancePath . '/typo3conf/ext/test_resources/bundle.phar';
+        include('phar://' . $allowedPath . '/Classes/Domain/Model/DemoModel.php');
+
+        self::assertTrue(
+            class_exists(
+                \TYPO3Demo\Demo\Domain\Model\DemoModel::class,
+                false
+            )
+        );
+    }
+
+    /**
+     * @test
+     */
+    public function streamOpenDeniesInvocationForFileOpen()
+    {
+        self::expectException(PharStreamWrapperException::class);
+        self::expectExceptionCode(1530103998);
+
+        $allowedPath = $this->instancePath . '/fileadmin/bundle.phar';
+        fopen('phar://' . $allowedPath . '/Resources/content.txt', 'r');
+    }
+
+    /**
+     * @test
+     */
+    public function streamOpenDeniesInvocationForFileGetContents()
+    {
+        self::expectException(PharStreamWrapperException::class);
+        self::expectExceptionCode(1530103998);
+
+        $allowedPath = $this->instancePath . '/fileadmin/bundle.phar';
+        file_get_contents('phar://' . $allowedPath . '/Resources/content.txt');
+    }
+
+    /**
+     * @test
+     */
+    public function streamOpenDeniesInvocationForInclude()
+    {
+        self::expectException(PharStreamWrapperException::class);
+        self::expectExceptionCode(1530103998);
+
+        $allowedPath = $this->instancePath . '/fileadmin/bundle.phar';
+        include('phar://' . $allowedPath . '/Classes/Domain/Model/DemoModel.php');
+    }
+}