Skip to content

Commit

Permalink
feat: Add IFilenameValidator to have one consistent place for filen…
Browse files Browse the repository at this point in the history
…ame validation

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
  • Loading branch information
susnux committed Jul 9, 2024
1 parent 025a784 commit 0392d91
Show file tree
Hide file tree
Showing 6 changed files with 482 additions and 0 deletions.
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_classmap.php
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@
'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',
Expand Down Expand Up @@ -1444,6 +1445,7 @@
'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',
Expand Down
2 changes: 2 additions & 0 deletions lib/composer/composer/autoload_static.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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',
Expand Down
249 changes: 249 additions & 0 deletions lib/private/Files/FilenameValidator.php
Original file line number Diff line number Diff line change
@@ -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_chars', []);
if (!is_array($additionalChars)) {
$this->logger->error('Invalid system config value for "forbidden_chars" 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 $filename
* @return bool True if invalid name, False otherwise
*/
public function isForbidden(string $filename): bool {
$filename = basename($filename);
$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]));
}
}
}
};
2 changes: 2 additions & 0 deletions lib/private/Server.php
Original file line number Diff line number Diff line change
Expand Up @@ -1371,6 +1371,8 @@ public function __construct($webRoot, \OC\Config $config) {

$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);
Expand Down
39 changes: 39 additions & 0 deletions lib/public/Files/IFilenameValidator.php
Original file line number Diff line number Diff line change
@@ -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;
}
Loading

0 comments on commit 0392d91

Please sign in to comment.