123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289 |
- <?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\InvalidDirectoryException;
- use OCP\Files\InvalidPathException;
- use OCP\Files\ReservedWordException;
- use OCP\IConfig;
- use OCP\IDBConnection;
- 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 $forbiddenBasenames = [];
- /**
- * @var list<string>
- */
- private array $forbiddenCharacters = [];
- /**
- * @var list<string>
- */
- private array $forbiddenExtensions = [];
- public function __construct(
- IFactory $l10nFactory,
- private IDBConnection $database,
- 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->getConfigValue('forbidden_filename_extensions', ['.filepart']);
- // Always forbid .part files as they are used internally
- $forbiddenExtensions[] = '.part';
- $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->getConfigValue('forbidden_filenames', ['.htaccess']);
- // Handle legacy config option
- // TODO: Drop with Nextcloud 34
- $legacyForbiddenNames = $this->getConfigValue('blacklisted_files', []);
- 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);
- // Ensure we are having a proper string list
- $this->forbiddenNames = array_values($forbiddenNames);
- }
- return $this->forbiddenNames;
- }
- /**
- * Get a list of forbidden file basenames 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 getForbiddenBasenames(): array {
- if (empty($this->forbiddenBasenames)) {
- $forbiddenBasenames = $this->getConfigValue('forbidden_filename_basenames', []);
- // Ensure we are having a proper string list
- $this->forbiddenBasenames = array_values($forbiddenBasenames);
- }
- return $this->forbiddenBasenames;
- }
- /**
- * 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
- // we check the trimmed name here to ensure unexpected trimming will not cause severe issues
- if ($trimmed === '.' || $trimmed === '..') {
- throw new InvalidDirectoryException($this->l10n->t('Dot files are not allowed'));
- }
- // 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->database->supports4ByteText()) {
- // verify database - e.g. mysql only 3-byte chars
- if (preg_match('%(?:
- \xF0[\x90-\xBF][\x80-\xBF]{2} # planes 1-3
- | [\xF1-\xF3][\x80-\xBF]{3} # planes 4-15
- | \xF4[\x80-\x8F][\x80-\xBF]{2} # plane 16
- )%xs', $filename)) {
- throw new InvalidCharacterInPathException();
- }
- }
- 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 {
- // We support paths here as this function is also used in some storage internals
- $filename = basename($path);
- $filename = mb_strtolower($filename);
- if ($filename === '') {
- return false;
- }
- // Check for forbidden filenames
- $forbiddenNames = $this->getForbiddenFilenames();
- if (in_array($filename, $forbiddenNames)) {
- return true;
- }
- // Check for forbidden basenames - basenames are the part of the file until the first dot
- // (except if the dot is the first character as this is then part of the basename "hidden files")
- $basename = substr($filename, 0, strpos($filename, '.', 1) ?: null);
- $forbiddenNames = $this->getForbiddenBasenames();
- 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($this->l10n->t('Invalid character "%1$s" in filename', [$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]));
- }
- }
- }
- /**
- * Helper to get lower case list from config with validation
- * @return string[]
- */
- private function getConfigValue(string $key, array $fallback): array {
- $values = $this->config->getSystemValue($key, $fallback);
- if (!is_array($values)) {
- $this->logger->error('Invalid system config value for "' . $key . '" is ignored.');
- $values = $fallback;
- }
- return array_map('mb_strtolower', $values);
- }
- };
|