diff options
author | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-07-03 16:33:40 +0200 |
---|---|---|
committer | Ferdinand Thiessen <opensource@fthiessen.de> | 2024-07-09 15:04:34 +0200 |
commit | 46f1efac41055d8fb349843140fefd021333de7b (patch) | |
tree | a04a9070ed71062952437299576bc102a334ce9a /lib | |
parent | 025a7849b487351d0240d89833b3ab825897097d (diff) | |
download | nextcloud-server-46f1efac41055d8fb349843140fefd021333de7b.tar.gz nextcloud-server-46f1efac41055d8fb349843140fefd021333de7b.zip |
feat: Add `IFilenameValidator` to have one consistent place for filename validation
Co-authored-by: Ferdinand Thiessen <opensource@fthiessen.de>
Co-authored-by: Côme Chilliet <91878298+come-nc@users.noreply.github.com>
Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Diffstat (limited to 'lib')
-rw-r--r-- | lib/composer/composer/autoload_classmap.php | 2 | ||||
-rw-r--r-- | lib/composer/composer/autoload_static.php | 2 | ||||
-rw-r--r-- | lib/private/Files/FilenameValidator.php | 249 | ||||
-rw-r--r-- | lib/private/Server.php | 2 | ||||
-rw-r--r-- | lib/public/Files/IFilenameValidator.php | 39 |
5 files changed, 294 insertions, 0 deletions
diff --git a/lib/composer/composer/autoload_classmap.php b/lib/composer/composer/autoload_classmap.php index f0addbcdaa8..9b6fd9c7e56 100644 --- a/lib/composer/composer/autoload_classmap.php +++ b/lib/composer/composer/autoload_classmap.php @@ -379,6 +379,7 @@ return array( 'OCP\\Files\\ForbiddenException' => $baseDir . '/lib/public/Files/ForbiddenException.php', 'OCP\\Files\\GenericFileException' => $baseDir . '/lib/public/Files/GenericFileException.php', 'OCP\\Files\\IAppData' => $baseDir . '/lib/public/Files/IAppData.php', + 'OCP\\Files\\IFilenameValidator' => $baseDir . '/lib/public/Files/IFilenameValidator.php', 'OCP\\Files\\IHomeStorage' => $baseDir . '/lib/public/Files/IHomeStorage.php', 'OCP\\Files\\IMimeTypeDetector' => $baseDir . '/lib/public/Files/IMimeTypeDetector.php', 'OCP\\Files\\IMimeTypeLoader' => $baseDir . '/lib/public/Files/IMimeTypeLoader.php', @@ -1444,6 +1445,7 @@ return array( 'OC\\Files\\Config\\UserMountCache' => $baseDir . '/lib/private/Files/Config/UserMountCache.php', 'OC\\Files\\Config\\UserMountCacheListener' => $baseDir . '/lib/private/Files/Config/UserMountCacheListener.php', 'OC\\Files\\FileInfo' => $baseDir . '/lib/private/Files/FileInfo.php', + 'OC\\Files\\FilenameValidator' => $baseDir . '/lib/private/Files/FilenameValidator.php', 'OC\\Files\\Filesystem' => $baseDir . '/lib/private/Files/Filesystem.php', 'OC\\Files\\Lock\\LockManager' => $baseDir . '/lib/private/Files/Lock/LockManager.php', 'OC\\Files\\Mount\\CacheMountProvider' => $baseDir . '/lib/private/Files/Mount/CacheMountProvider.php', diff --git a/lib/composer/composer/autoload_static.php b/lib/composer/composer/autoload_static.php index 51044d28a46..0f387cb6980 100644 --- a/lib/composer/composer/autoload_static.php +++ b/lib/composer/composer/autoload_static.php @@ -412,6 +412,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OCP\\Files\\ForbiddenException' => __DIR__ . '/../../..' . '/lib/public/Files/ForbiddenException.php', 'OCP\\Files\\GenericFileException' => __DIR__ . '/../../..' . '/lib/public/Files/GenericFileException.php', 'OCP\\Files\\IAppData' => __DIR__ . '/../../..' . '/lib/public/Files/IAppData.php', + 'OCP\\Files\\IFilenameValidator' => __DIR__ . '/../../..' . '/lib/public/Files/IFilenameValidator.php', 'OCP\\Files\\IHomeStorage' => __DIR__ . '/../../..' . '/lib/public/Files/IHomeStorage.php', 'OCP\\Files\\IMimeTypeDetector' => __DIR__ . '/../../..' . '/lib/public/Files/IMimeTypeDetector.php', 'OCP\\Files\\IMimeTypeLoader' => __DIR__ . '/../../..' . '/lib/public/Files/IMimeTypeLoader.php', @@ -1477,6 +1478,7 @@ class ComposerStaticInit749170dad3f5e7f9ca158f5a9f04f6a2 'OC\\Files\\Config\\UserMountCache' => __DIR__ . '/../../..' . '/lib/private/Files/Config/UserMountCache.php', 'OC\\Files\\Config\\UserMountCacheListener' => __DIR__ . '/../../..' . '/lib/private/Files/Config/UserMountCacheListener.php', 'OC\\Files\\FileInfo' => __DIR__ . '/../../..' . '/lib/private/Files/FileInfo.php', + 'OC\\Files\\FilenameValidator' => __DIR__ . '/../../..' . '/lib/private/Files/FilenameValidator.php', 'OC\\Files\\Filesystem' => __DIR__ . '/../../..' . '/lib/private/Files/Filesystem.php', 'OC\\Files\\Lock\\LockManager' => __DIR__ . '/../../..' . '/lib/private/Files/Lock/LockManager.php', 'OC\\Files\\Mount\\CacheMountProvider' => __DIR__ . '/../../..' . '/lib/private/Files/Mount/CacheMountProvider.php', diff --git a/lib/private/Files/FilenameValidator.php b/lib/private/Files/FilenameValidator.php new file mode 100644 index 00000000000..d650089e5c4 --- /dev/null +++ b/lib/private/Files/FilenameValidator.php @@ -0,0 +1,249 @@ +<?php + +declare(strict_types=1); +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OC\Files; + +use OCP\Files\EmptyFileNameException; +use OCP\Files\FileNameTooLongException; +use OCP\Files\IFilenameValidator; +use OCP\Files\InvalidCharacterInPathException; +use OCP\Files\InvalidPathException; +use OCP\Files\ReservedWordException; +use OCP\IConfig; +use OCP\IL10N; +use OCP\L10N\IFactory; +use Psr\Log\LoggerInterface; + +/** + * @since 30.0.0 + */ +class FilenameValidator implements IFilenameValidator { + + private IL10N $l10n; + + /** + * @var list<string> + */ + private array $forbiddenNames = []; + + /** + * @var list<string> + */ + private array $forbiddenCharacters = []; + + /** + * @var list<string> + */ + private array $forbiddenExtensions = []; + + public function __construct( + IFactory $l10nFactory, + private IConfig $config, + private LoggerInterface $logger, + ) { + $this->l10n = $l10nFactory->get('core'); + } + + /** + * Get a list of reserved filenames that must not be used + * This list should be checked case-insensitive, all names are returned lowercase. + * @return list<string> + * @since 30.0.0 + */ + public function getForbiddenExtensions(): array { + if (empty($this->forbiddenExtensions)) { + $forbiddenExtensions = $this->config->getSystemValue('forbidden_filename_extensions', ['.filepart']); + if (!is_array($forbiddenExtensions)) { + $this->logger->error('Invalid system config value for "forbidden_filename_extensions" is ignored.'); + $forbiddenExtensions = ['.filepart']; + } + + // Always forbid .part files as they are used internally + $forbiddenExtensions = array_merge($forbiddenExtensions, ['.part']); + + // The list is case insensitive so we provide it always lowercase + $forbiddenExtensions = array_map('mb_strtolower', $forbiddenExtensions); + $this->forbiddenExtensions = array_values($forbiddenExtensions); + } + return $this->forbiddenExtensions; + } + + /** + * Get a list of forbidden filename extensions that must not be used + * This list should be checked case-insensitive, all names are returned lowercase. + * @return list<string> + * @since 30.0.0 + */ + public function getForbiddenFilenames(): array { + if (empty($this->forbiddenNames)) { + $forbiddenNames = $this->config->getSystemValue('forbidden_filenames', ['.htaccess']); + if (!is_array($forbiddenNames)) { + $this->logger->error('Invalid system config value for "forbidden_filenames" is ignored.'); + $forbiddenNames = ['.htaccess']; + } + + // Handle legacy config option + // TODO: Drop with Nextcloud 34 + $legacyForbiddenNames = $this->config->getSystemValue('blacklisted_files', []); + if (!is_array($legacyForbiddenNames)) { + $this->logger->error('Invalid system config value for "blacklisted_files" is ignored.'); + $legacyForbiddenNames = []; + } + if (!empty($legacyForbiddenNames)) { + $this->logger->warning('System config option "blacklisted_files" is deprecated and will be removed in Nextcloud 34, use "forbidden_filenames" instead.'); + } + $forbiddenNames = array_merge($legacyForbiddenNames, $forbiddenNames); + + // The list is case insensitive so we provide it always lowercase + $forbiddenNames = array_map('mb_strtolower', $forbiddenNames); + $this->forbiddenNames = array_values($forbiddenNames); + } + return $this->forbiddenNames; + } + + /** + * Get a list of characters forbidden in filenames + * + * Note: Characters in the range [0-31] are always forbidden, + * even if not inside this list (see OCP\Files\Storage\IStorage::verifyPath). + * + * @return list<string> + * @since 30.0.0 + */ + public function getForbiddenCharacters(): array { + if (empty($this->forbiddenCharacters)) { + // Get always forbidden characters + $forbiddenCharacters = str_split(\OCP\Constants::FILENAME_INVALID_CHARS); + if ($forbiddenCharacters === false) { + $forbiddenCharacters = []; + } + + // Get admin defined invalid characters + $additionalChars = $this->config->getSystemValue('forbidden_filename_characters', []); + if (!is_array($additionalChars)) { + $this->logger->error('Invalid system config value for "forbidden_filename_characters" is ignored.'); + $additionalChars = []; + } + $forbiddenCharacters = array_merge($forbiddenCharacters, $additionalChars); + + // Handle legacy config option + // TODO: Drop with Nextcloud 34 + $legacyForbiddenCharacters = $this->config->getSystemValue('forbidden_chars', []); + if (!is_array($legacyForbiddenCharacters)) { + $this->logger->error('Invalid system config value for "forbidden_chars" is ignored.'); + $legacyForbiddenCharacters = []; + } + if (!empty($legacyForbiddenCharacters)) { + $this->logger->warning('System config option "forbidden_chars" is deprecated and will be removed in Nextcloud 34, use "forbidden_filename_characters" instead.'); + } + $forbiddenCharacters = array_merge($legacyForbiddenCharacters, $forbiddenCharacters); + + $this->forbiddenCharacters = array_values($forbiddenCharacters); + } + return $this->forbiddenCharacters; + } + + /** + * @inheritdoc + */ + public function isFilenameValid(string $filename): bool { + try { + $this->validateFilename($filename); + } catch (\OCP\Files\InvalidPathException) { + return false; + } + return true; + } + + /** + * @inheritdoc + */ + public function validateFilename(string $filename): void { + $trimmed = trim($filename); + if ($trimmed === '') { + throw new EmptyFileNameException(); + } + + // the special directories . and .. would cause never ending recursion + if ($trimmed === '.' || $trimmed === '..') { + throw new ReservedWordException(); + } + + // 255 characters is the limit on common file systems (ext/xfs) + // oc_filecache has a 250 char length limit for the filename + if (isset($filename[250])) { + throw new FileNameTooLongException(); + } + + if ($this->isForbidden($filename)) { + throw new ReservedWordException(); + } + + $this->checkForbiddenExtension($filename); + + $this->checkForbiddenCharacters($filename); + } + + /** + * Check if the filename is forbidden + * @param string $path Path to check the filename + * @return bool True if invalid name, False otherwise + */ + public function isForbidden(string $path): bool { + $filename = basename($path); + $filename = mb_strtolower($filename); + + if ($filename === '') { + return false; + } + + // The name part without extension + $basename = substr($filename, 0, strpos($filename, '.', 1) ?: null); + // Check for forbidden filenames + $forbiddenNames = $this->getForbiddenFilenames(); + if (in_array($basename, $forbiddenNames)) { + return true; + } + + // Filename is not forbidden + return false; + } + + /** + * Check if a filename contains any of the forbidden characters + * @param string $filename + * @throws InvalidCharacterInPathException + */ + protected function checkForbiddenCharacters(string $filename): void { + $sanitizedFileName = filter_var($filename, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW); + if ($sanitizedFileName !== $filename) { + throw new InvalidCharacterInPathException(); + } + + foreach ($this->getForbiddenCharacters() as $char) { + if (str_contains($filename, $char)) { + throw new InvalidCharacterInPathException($char); + } + } + } + + /** + * Check if a filename has a forbidden filename extension + * @param string $filename The filename to validate + * @throws InvalidPathException + */ + protected function checkForbiddenExtension(string $filename): void { + $filename = mb_strtolower($filename); + // Check for forbidden filename exten<sions + $forbiddenExtensions = $this->getForbiddenExtensions(); + foreach ($forbiddenExtensions as $extension) { + if (str_ends_with($filename, $extension)) { + throw new InvalidPathException($this->l10n->t('Invalid filename extension "%1$s"', [$extension])); + } + } + } +}; diff --git a/lib/private/Server.php b/lib/private/Server.php index bcdf482f02d..795d72e3076 100644 --- a/lib/private/Server.php +++ b/lib/private/Server.php @@ -1371,6 +1371,8 @@ class Server extends ServerContainer implements IServerContainer { $this->registerAlias(\OCP\Files\AppData\IAppDataFactory::class, \OC\Files\AppData\Factory::class); + $this->registerAlias(\OCP\Files\IFilenameValidator::class, \OC\Files\FilenameValidator::class); + $this->registerAlias(IBinaryFinder::class, BinaryFinder::class); $this->registerAlias(\OCP\Share\IPublicShareTemplateFactory::class, \OC\Share20\PublicShareTemplateFactory::class); diff --git a/lib/public/Files/IFilenameValidator.php b/lib/public/Files/IFilenameValidator.php new file mode 100644 index 00000000000..2bd3bb945dc --- /dev/null +++ b/lib/public/Files/IFilenameValidator.php @@ -0,0 +1,39 @@ +<?php + +declare(strict_types=1); + +/** + * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +namespace OCP\Files; + +/** + * @since 30.0.0 + */ +interface IFilenameValidator { + + /** + * It is recommended to use `\OCP\Files\Storage\IStorage::isFileValid` instead as this + * only checks if the filename is valid in general but not for a specific storage + * which might have additional naming rules. + * + * @param string $filename The filename to check for validity + * @return bool + * @since 30.0.0 + */ + public function isFilenameValid(string $filename): bool; + + /** + * It is recommended to use `\OCP\Files\Storage\IStorage::isFileValid` instead as this + * only checks if the filename is valid in general but not for a specific storage + * which might have additional naming rules. + * + * This will validate a filename and throw an exception with details on error. + * + * @param string $filename The filename to check for validity + * @throws \OCP\Files\InvalidPathException or one of its child classes in case of an error + * @since 30.0.0 + */ + public function validateFilename(string $filename): void; +} |