FilenameValidator.php 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. <?php
  2. declare(strict_types=1);
  3. /**
  4. * SPDX-FileCopyrightText: 2024 Nextcloud GmbH and Nextcloud contributors
  5. * SPDX-License-Identifier: AGPL-3.0-or-later
  6. */
  7. namespace OC\Files;
  8. use OCP\Files\EmptyFileNameException;
  9. use OCP\Files\FileNameTooLongException;
  10. use OCP\Files\IFilenameValidator;
  11. use OCP\Files\InvalidCharacterInPathException;
  12. use OCP\Files\InvalidDirectoryException;
  13. use OCP\Files\InvalidPathException;
  14. use OCP\Files\ReservedWordException;
  15. use OCP\IConfig;
  16. use OCP\IDBConnection;
  17. use OCP\IL10N;
  18. use OCP\L10N\IFactory;
  19. use Psr\Log\LoggerInterface;
  20. /**
  21. * @since 30.0.0
  22. */
  23. class FilenameValidator implements IFilenameValidator {
  24. public const INVALID_FILE_TYPE = 100;
  25. private IL10N $l10n;
  26. /**
  27. * @var list<string>
  28. */
  29. private array $forbiddenNames = [];
  30. /**
  31. * @var list<string>
  32. */
  33. private array $forbiddenBasenames = [];
  34. /**
  35. * @var list<string>
  36. */
  37. private array $forbiddenCharacters = [];
  38. /**
  39. * @var list<string>
  40. */
  41. private array $forbiddenExtensions = [];
  42. public function __construct(
  43. IFactory $l10nFactory,
  44. private IDBConnection $database,
  45. private IConfig $config,
  46. private LoggerInterface $logger,
  47. ) {
  48. $this->l10n = $l10nFactory->get('core');
  49. }
  50. /**
  51. * Get a list of reserved filenames that must not be used
  52. * This list should be checked case-insensitive, all names are returned lowercase.
  53. * @return list<string>
  54. * @since 30.0.0
  55. */
  56. public function getForbiddenExtensions(): array {
  57. if (empty($this->forbiddenExtensions)) {
  58. $forbiddenExtensions = $this->getConfigValue('forbidden_filename_extensions', ['.filepart']);
  59. // Always forbid .part files as they are used internally
  60. $forbiddenExtensions[] = '.part';
  61. $this->forbiddenExtensions = array_values($forbiddenExtensions);
  62. }
  63. return $this->forbiddenExtensions;
  64. }
  65. /**
  66. * Get a list of forbidden filename extensions that must not be used
  67. * This list should be checked case-insensitive, all names are returned lowercase.
  68. * @return list<string>
  69. * @since 30.0.0
  70. */
  71. public function getForbiddenFilenames(): array {
  72. if (empty($this->forbiddenNames)) {
  73. $forbiddenNames = $this->getConfigValue('forbidden_filenames', ['.htaccess']);
  74. // Handle legacy config option
  75. // TODO: Drop with Nextcloud 34
  76. $legacyForbiddenNames = $this->getConfigValue('blacklisted_files', []);
  77. if (!empty($legacyForbiddenNames)) {
  78. $this->logger->warning('System config option "blacklisted_files" is deprecated and will be removed in Nextcloud 34, use "forbidden_filenames" instead.');
  79. }
  80. $forbiddenNames = array_merge($legacyForbiddenNames, $forbiddenNames);
  81. // Ensure we are having a proper string list
  82. $this->forbiddenNames = array_values($forbiddenNames);
  83. }
  84. return $this->forbiddenNames;
  85. }
  86. /**
  87. * Get a list of forbidden file basenames that must not be used
  88. * This list should be checked case-insensitive, all names are returned lowercase.
  89. * @return list<string>
  90. * @since 30.0.0
  91. */
  92. public function getForbiddenBasenames(): array {
  93. if (empty($this->forbiddenBasenames)) {
  94. $forbiddenBasenames = $this->getConfigValue('forbidden_filename_basenames', []);
  95. // Ensure we are having a proper string list
  96. $this->forbiddenBasenames = array_values($forbiddenBasenames);
  97. }
  98. return $this->forbiddenBasenames;
  99. }
  100. /**
  101. * Get a list of characters forbidden in filenames
  102. *
  103. * Note: Characters in the range [0-31] are always forbidden,
  104. * even if not inside this list (see OCP\Files\Storage\IStorage::verifyPath).
  105. *
  106. * @return list<string>
  107. * @since 30.0.0
  108. */
  109. public function getForbiddenCharacters(): array {
  110. if (empty($this->forbiddenCharacters)) {
  111. // Get always forbidden characters
  112. $forbiddenCharacters = str_split(\OCP\Constants::FILENAME_INVALID_CHARS);
  113. if ($forbiddenCharacters === false) {
  114. $forbiddenCharacters = [];
  115. }
  116. // Get admin defined invalid characters
  117. $additionalChars = $this->config->getSystemValue('forbidden_filename_characters', []);
  118. if (!is_array($additionalChars)) {
  119. $this->logger->error('Invalid system config value for "forbidden_filename_characters" is ignored.');
  120. $additionalChars = [];
  121. }
  122. $forbiddenCharacters = array_merge($forbiddenCharacters, $additionalChars);
  123. // Handle legacy config option
  124. // TODO: Drop with Nextcloud 34
  125. $legacyForbiddenCharacters = $this->config->getSystemValue('forbidden_chars', []);
  126. if (!is_array($legacyForbiddenCharacters)) {
  127. $this->logger->error('Invalid system config value for "forbidden_chars" is ignored.');
  128. $legacyForbiddenCharacters = [];
  129. }
  130. if (!empty($legacyForbiddenCharacters)) {
  131. $this->logger->warning('System config option "forbidden_chars" is deprecated and will be removed in Nextcloud 34, use "forbidden_filename_characters" instead.');
  132. }
  133. $forbiddenCharacters = array_merge($legacyForbiddenCharacters, $forbiddenCharacters);
  134. $this->forbiddenCharacters = array_values($forbiddenCharacters);
  135. }
  136. return $this->forbiddenCharacters;
  137. }
  138. /**
  139. * @inheritdoc
  140. */
  141. public function isFilenameValid(string $filename): bool {
  142. try {
  143. $this->validateFilename($filename);
  144. } catch (\OCP\Files\InvalidPathException) {
  145. return false;
  146. }
  147. return true;
  148. }
  149. /**
  150. * @inheritdoc
  151. */
  152. public function validateFilename(string $filename): void {
  153. $trimmed = trim($filename);
  154. if ($trimmed === '') {
  155. throw new EmptyFileNameException();
  156. }
  157. // the special directories . and .. would cause never ending recursion
  158. // we check the trimmed name here to ensure unexpected trimming will not cause severe issues
  159. if ($trimmed === '.' || $trimmed === '..') {
  160. throw new InvalidDirectoryException($this->l10n->t('Dot files are not allowed'));
  161. }
  162. // 255 characters is the limit on common file systems (ext/xfs)
  163. // oc_filecache has a 250 char length limit for the filename
  164. if (isset($filename[250])) {
  165. throw new FileNameTooLongException();
  166. }
  167. if (!$this->database->supports4ByteText()) {
  168. // verify database - e.g. mysql only 3-byte chars
  169. if (preg_match('%(?:
  170. \xF0[\x90-\xBF][\x80-\xBF]{2} # planes 1-3
  171. | [\xF1-\xF3][\x80-\xBF]{3} # planes 4-15
  172. | \xF4[\x80-\x8F][\x80-\xBF]{2} # plane 16
  173. )%xs', $filename)) {
  174. throw new InvalidCharacterInPathException();
  175. }
  176. }
  177. $this->checkForbiddenName($filename);
  178. $this->checkForbiddenExtension($filename);
  179. $this->checkForbiddenCharacters($filename);
  180. }
  181. /**
  182. * Check if the filename is forbidden
  183. * @param string $path Path to check the filename
  184. * @return bool True if invalid name, False otherwise
  185. */
  186. public function isForbidden(string $path): bool {
  187. // We support paths here as this function is also used in some storage internals
  188. $filename = basename($path);
  189. $filename = mb_strtolower($filename);
  190. if ($filename === '') {
  191. return false;
  192. }
  193. // Check for forbidden filenames
  194. $forbiddenNames = $this->getForbiddenFilenames();
  195. if (in_array($filename, $forbiddenNames)) {
  196. return true;
  197. }
  198. // Filename is not forbidden
  199. return false;
  200. }
  201. protected function checkForbiddenName($filename): void {
  202. if ($this->isForbidden($filename)) {
  203. throw new ReservedWordException($this->l10n->t('"%1$s" is a forbidden file or folder name.', [$filename]));
  204. }
  205. // Check for forbidden basenames - basenames are the part of the file until the first dot
  206. // (except if the dot is the first character as this is then part of the basename "hidden files")
  207. $basename = substr($filename, 0, strpos($filename, '.', 1) ?: null);
  208. $forbiddenNames = $this->getForbiddenBasenames();
  209. if (in_array($basename, $forbiddenNames)) {
  210. throw new ReservedWordException($this->l10n->t('"%1$s" is a forbidden prefix for file or folder names.', [$filename]));
  211. }
  212. }
  213. /**
  214. * Check if a filename contains any of the forbidden characters
  215. * @param string $filename
  216. * @throws InvalidCharacterInPathException
  217. */
  218. protected function checkForbiddenCharacters(string $filename): void {
  219. $sanitizedFileName = filter_var($filename, FILTER_UNSAFE_RAW, FILTER_FLAG_STRIP_LOW);
  220. if ($sanitizedFileName !== $filename) {
  221. throw new InvalidCharacterInPathException();
  222. }
  223. foreach ($this->getForbiddenCharacters() as $char) {
  224. if (str_contains($filename, $char)) {
  225. throw new InvalidCharacterInPathException($this->l10n->t('"%1$s" is not allowed inside a file or folder name.', [$char]));
  226. }
  227. }
  228. }
  229. /**
  230. * Check if a filename has a forbidden filename extension
  231. * @param string $filename The filename to validate
  232. * @throws InvalidPathException
  233. */
  234. protected function checkForbiddenExtension(string $filename): void {
  235. $filename = mb_strtolower($filename);
  236. // Check for forbidden filename extensions
  237. $forbiddenExtensions = $this->getForbiddenExtensions();
  238. foreach ($forbiddenExtensions as $extension) {
  239. if (str_ends_with($filename, $extension)) {
  240. if (str_starts_with($extension, '.')) {
  241. throw new InvalidPathException($this->l10n->t('"%1$s" is a forbidden file type.', [$extension]), self::INVALID_FILE_TYPE);
  242. } else {
  243. throw new InvalidPathException($this->l10n->t('Filenames must not end with "%1$s".', [$extension]));
  244. }
  245. }
  246. }
  247. }
  248. /**
  249. * Helper to get lower case list from config with validation
  250. * @return string[]
  251. */
  252. private function getConfigValue(string $key, array $fallback): array {
  253. $values = $this->config->getSystemValue($key, $fallback);
  254. if (!is_array($values)) {
  255. $this->logger->error('Invalid system config value for "' . $key . '" is ignored.');
  256. $values = $fallback;
  257. }
  258. return array_map('mb_strtolower', $values);
  259. }
  260. };