2024-07-03 10:33:40 -04:00
< ? 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 ;
2024-07-15 10:28:43 -04:00
use OCP\Files\InvalidDirectoryException ;
2024-07-03 10:33:40 -04:00
use OCP\Files\InvalidPathException ;
use OCP\Files\ReservedWordException ;
use OCP\IConfig ;
2024-07-15 10:28:43 -04:00
use OCP\IDBConnection ;
2024-07-03 10:33:40 -04:00
use OCP\IL10N ;
use OCP\L10N\IFactory ;
use Psr\Log\LoggerInterface ;
/**
* @ since 30.0 . 0
*/
class FilenameValidator implements IFilenameValidator {
2024-08-27 08:07:44 -04:00
public const INVALID_FILE_TYPE = 100 ;
2024-07-03 10:33:40 -04:00
private IL10N $l10n ;
/**
* @ var list < string >
*/
private array $forbiddenNames = [];
2024-07-15 13:10:52 -04:00
/**
* @ var list < string >
*/
private array $forbiddenBasenames = [];
2024-07-03 10:33:40 -04:00
/**
* @ var list < string >
*/
private array $forbiddenCharacters = [];
/**
* @ var list < string >
*/
private array $forbiddenExtensions = [];
public function __construct (
IFactory $l10nFactory ,
2024-07-15 10:28:43 -04:00
private IDBConnection $database ,
2024-07-03 10:33:40 -04:00
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 )) {
2024-07-15 13:10:52 -04:00
$forbiddenExtensions = $this -> getConfigValue ( 'forbidden_filename_extensions' , [ '.filepart' ]);
2024-07-03 10:33:40 -04:00
// Always forbid .part files as they are used internally
2024-07-15 13:10:52 -04:00
$forbiddenExtensions [] = '.part' ;
2024-07-03 10:33:40 -04:00
$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 )) {
2024-07-15 13:10:52 -04:00
$forbiddenNames = $this -> getConfigValue ( 'forbidden_filenames' , [ '.htaccess' ]);
2024-07-03 10:33:40 -04:00
// Handle legacy config option
// TODO: Drop with Nextcloud 34
2024-07-15 13:10:52 -04:00
$legacyForbiddenNames = $this -> getConfigValue ( 'blacklisted_files' , []);
2024-07-03 10:33:40 -04:00
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 );
2024-07-15 13:10:52 -04:00
// Ensure we are having a proper string list
2024-07-03 10:33:40 -04:00
$this -> forbiddenNames = array_values ( $forbiddenNames );
}
return $this -> forbiddenNames ;
}
2024-07-15 13:10:52 -04:00
/**
* 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 ;
}
2024-07-03 10:33:40 -04:00
/**
* 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
2024-07-15 10:28:43 -04:00
// we check the trimmed name here to ensure unexpected trimming will not cause severe issues
2024-07-03 10:33:40 -04:00
if ( $trimmed === '.' || $trimmed === '..' ) {
2024-07-15 10:28:43 -04:00
throw new InvalidDirectoryException ( $this -> l10n -> t ( 'Dot files are not allowed' ));
2024-07-03 10:33:40 -04:00
}
// 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 ();
}
2024-07-15 10:28:43 -04:00
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 ();
}
}
2024-08-12 12:11:31 -04:00
$this -> checkForbiddenName ( $filename );
2024-07-03 10:33:40 -04:00
$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 {
2024-07-15 13:10:52 -04:00
// We support paths here as this function is also used in some storage internals
2024-07-03 10:33:40 -04:00
$filename = basename ( $path );
$filename = mb_strtolower ( $filename );
if ( $filename === '' ) {
return false ;
}
// Check for forbidden filenames
$forbiddenNames = $this -> getForbiddenFilenames ();
2024-07-15 13:10:52 -04:00
if ( in_array ( $filename , $forbiddenNames )) {
return true ;
}
2024-08-12 12:11:31 -04:00
// Filename is not forbidden
return false ;
}
protected function checkForbiddenName ( $filename ) : void {
if ( $this -> isForbidden ( $filename )) {
throw new ReservedWordException ( $this -> l10n -> t ( '"%1$s" is a forbidden file or folder name.' , [ $filename ]));
}
2024-07-15 13:10:52 -04:00
// 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 ();
2024-07-03 10:33:40 -04:00
if ( in_array ( $basename , $forbiddenNames )) {
2024-08-12 12:11:31 -04:00
throw new ReservedWordException ( $this -> l10n -> t ( '"%1$s" is a forbidden prefix for file or folder names.' , [ $filename ]));
2024-07-03 10:33:40 -04:00
}
}
2024-08-12 12:11:31 -04:00
2024-07-03 10:33:40 -04:00
/**
* 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 )) {
2024-08-12 12:11:31 -04:00
throw new InvalidCharacterInPathException ( $this -> l10n -> t ( '"%1$s" is not allowed inside a file or folder name.' , [ $char ]));
2024-07-03 10:33:40 -04:00
}
}
}
/**
* 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 );
2024-08-27 08:07:44 -04:00
// Check for forbidden filename extensions
2024-07-03 10:33:40 -04:00
$forbiddenExtensions = $this -> getForbiddenExtensions ();
foreach ( $forbiddenExtensions as $extension ) {
if ( str_ends_with ( $filename , $extension )) {
2024-08-12 12:11:31 -04:00
if ( str_starts_with ( $extension , '.' )) {
2024-08-27 08:07:44 -04:00
throw new InvalidPathException ( $this -> l10n -> t ( '"%1$s" is a forbidden file type.' , [ $extension ]), self :: INVALID_FILE_TYPE );
2024-08-12 12:11:31 -04:00
} else {
throw new InvalidPathException ( $this -> l10n -> t ( 'Filenames must not end with "%1$s".' , [ $extension ]));
}
2024-07-03 10:33:40 -04:00
}
}
}
2024-07-15 13:10:52 -04:00
/**
* Helper to get lower case list from config with validation
* @ return string []
*/
private function getConfigValue ( string $key , array $fallback ) : array {
2024-07-15 10:28:43 -04:00
$values = $this -> config -> getSystemValue ( $key , $fallback );
2024-07-15 13:10:52 -04:00
if ( ! is_array ( $values )) {
$this -> logger -> error ( 'Invalid system config value for "' . $key . '" is ignored.' );
$values = $fallback ;
}
return array_map ( 'mb_strtolower' , $values );
}
2024-07-03 10:33:40 -04:00
};