FilenameValidator.php 8.8 KB

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