feat: Allow to enforce Windows compatible file and folder names

This will:
* Deny characters forbidden on Windows
* Deny files with names which are reserved on Windows
* Deny trailing dot or space
* Deny files or folders which are not case-insensitive unique in a folder

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
Ferdinand Thiessen 1 month ago
parent cc308f7b6a
commit eda5dca4cb
No known key found for this signature in database
GPG Key ID: 45FAE7268762B400

@ -1960,6 +1960,21 @@ $CONFIG = [
*/
'updatedirectory' => '',
/**
* Allow to enforce Windows compatible file and folder names.
* Nextcloud by default supports all files valid on Linux,
* but as Windows has some stricter filename rules this can lead to sync errors when using Windows clients.
*
* To enforce only Windows compatible filenames, also on the webui, set this value to ``true``.
*
* This will deny filenames with characters not valid on Windows, as well as some reserved filenames and nameing rules (no trailing dot or space).
* Additionally this will enforce files to be case in-sensitivly unique in a folder.
*
* Defaults to ``false``
*
*/
'enforce_windows_compatibility' => false,
/**
* Deny a specific file or files and disallow the upload of files
* with this name. ``.htaccess`` is blocked by default.

@ -63,6 +63,7 @@ use OCP\Files\Storage\ILockingStorage;
use OCP\Files\Storage\IStorage;
use OCP\Files\Storage\IWriteStreamStorage;
use OCP\Files\StorageNotAvailableException;
use OCP\IConfig;
use OCP\Lock\ILockingProvider;
use OCP\Lock\LockedException;
use OCP\Server;
@ -375,7 +376,7 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage {
}
if (!isset($this->watcher)) {
$this->watcher = new Watcher($storage);
$globalPolicy = \OC::$server->getConfig()->getSystemValue('filesystem_check_changes', Watcher::CHECK_NEVER);
$globalPolicy = \OCP\Server::get(IConfig::class)->getSystemValue('filesystem_check_changes', Watcher::CHECK_NEVER);
$this->watcher->setPolicy((int)$this->getMountOption('filesystem_check_changes', $globalPolicy));
}
return $this->watcher;
@ -565,6 +566,31 @@ abstract class Common implements Storage, ILockingStorage, IWriteStreamStorage {
if (\OC\Files\Filesystem::hasFilenameInvalidCharacters($fileName)) {
throw new InvalidCharacterInPathException();
}
$config = \OCP\Server::get(IConfig::class);
if ($config->getSystemValueBool('enforce_windows_compatibility', false)) {
// Windows does not allow filenames to end with a trailing dot or space
if (str_ends_with($fileName, '.') || str_ends_with($fileName, ' ')) {
throw new InvalidCharacterInPathException('Filenames must not end with a dot or space');
}
// Windows has path namespaces so e.g. `NUL` is a reserved word,
// but `NUL.txt` or `NUL.tar.gz` is considered the same and thus also reserved.
$basename = substr($fileName, 0, strpos($fileName, '.') ?: null);
if (\OC\Files\Filesystem::isFileBlacklisted(strtoupper($basename))) {
throw new ReservedWordException();
}
// Some windows systems are case insensitive,
// so to guarantee files can be synced we need to enfore case insensitivity
$content = $this->getDirectoryContent(dirname($path));
$fileName = strtolower($fileName);
foreach ($content as $subPath) {
if (strtolower($subPath['name']) === $fileName) {
throw new InvalidPathException('Filename is not case insensitivly unique');
}
}
}
// NOTE: $path will remain unverified for now
}

@ -621,17 +621,20 @@ class View {
* @param string|resource $data
* @return bool|mixed
* @throws LockedException
* @throws InvalidPathException When path is not within the expected root
*/
public function file_put_contents($path, $data) {
if (is_resource($data)) { //not having to deal with streams in file_put_contents makes life easier
if (!is_resource($data)) {
//not having to deal with streams in file_put_contents makes life easier
$hooks = $this->file_exists($path) ? ['update', 'write'] : ['create', 'write'];
return $this->basicOperation('file_put_contents', $path, $hooks, $data);
} else {
$absolutePath = Filesystem::normalizePath($this->getAbsolutePath($path));
if (Filesystem::isValidPath($path)
&& !Filesystem::isFileBlacklisted($path)
) {
$path = $this->getRelativePath($absolutePath);
if ($path === null) {
throw new InvalidPathException("Path $absolutePath is not in the expected root");
}
$this->verifyPath(dirname($path), basename($path));
$this->lockFile($path, ILockingProvider::LOCK_SHARED);
@ -671,15 +674,8 @@ class View {
$this->unlockFile($path, ILockingProvider::LOCK_SHARED);
return $result;
} else {
$this->unlockFile($path, ILockingProvider::LOCK_EXCLUSIVE);
return false;
}
} else {
return false;
}
} else {
$hooks = $this->file_exists($path) ? ['update', 'write'] : ['create', 'write'];
return $this->basicOperation('file_put_contents', $path, $hooks, $data);
}
}
@ -735,20 +731,21 @@ class View {
$absolutePath2 = Filesystem::normalizePath($this->getAbsolutePath($target));
$targetParts = explode('/', $absolutePath2);
$targetUser = $targetParts[1] ?? null;
$result = false;
if (
Filesystem::isValidPath($target)
&& Filesystem::isValidPath($source)
&& !Filesystem::isFileBlacklisted($target)
) {
if (!Filesystem::isValidPath($source) || !Filesystem::isValidPath($target)) {
return false;
}
$source = $this->getRelativePath($absolutePath1);
$target = $this->getRelativePath($absolutePath2);
$exists = $this->file_exists($target);
if ($source == null || $target == null) {
return false;
}
$this->verifyPath(dirname($target), basename($target));
$result = false;
$this->lockFile($source, ILockingProvider::LOCK_SHARED, true);
try {
$this->lockFile($target, ILockingProvider::LOCK_SHARED, true);
@ -772,8 +769,6 @@ class View {
}
}
if ($run) {
$this->verifyPath(dirname($target), basename($target));
$manager = Filesystem::getMountManager();
$mount1 = $this->getMount($source);
$mount2 = $this->getMount($target);
@ -856,7 +851,6 @@ class View {
$this->unlockFile($source, ILockingProvider::LOCK_SHARED, true);
$this->unlockFile($target, ILockingProvider::LOCK_SHARED, true);
}
}
return $result;
}
@ -872,26 +866,27 @@ class View {
public function copy($source, $target, $preserveMtime = false) {
$absolutePath1 = Filesystem::normalizePath($this->getAbsolutePath($source));
$absolutePath2 = Filesystem::normalizePath($this->getAbsolutePath($target));
$result = false;
if (
Filesystem::isValidPath($target)
&& Filesystem::isValidPath($source)
&& !Filesystem::isFileBlacklisted($target)
) {
if (!Filesystem::isValidPath($source) || !Filesystem::isValidPath($target)) {
return false;
}
$source = $this->getRelativePath($absolutePath1);
$target = $this->getRelativePath($absolutePath2);
if ($source == null || $target == null) {
return false;
}
$run = true;
$this->verifyPath(dirname($target), basename($target));
$this->lockFile($target, ILockingProvider::LOCK_SHARED);
$this->lockFile($source, ILockingProvider::LOCK_SHARED);
$lockTypePath1 = ILockingProvider::LOCK_SHARED;
$lockTypePath2 = ILockingProvider::LOCK_SHARED;
$result = false;
try {
$run = true;
$exists = $this->file_exists($target);
if ($this->shouldEmitHooks()) {
\OC_Hook::emit(
@ -944,11 +939,8 @@ class View {
}
}
} catch (\Exception $e) {
$this->unlockFile($target, $lockTypePath2);
$this->unlockFile($source, $lockTypePath1);
throw $e;
}
} finally {
$this->unlockFile($target, $lockTypePath2);
$this->unlockFile($source, $lockTypePath1);
}
@ -1132,14 +1124,18 @@ class View {
private function basicOperation(string $operation, string $path, array $hooks = [], $extraParam = null) {
$postFix = (substr($path, -1) === '/') ? '/' : '';
$absolutePath = Filesystem::normalizePath($this->getAbsolutePath($path));
if (Filesystem::isValidPath($path)
&& !Filesystem::isFileBlacklisted($path)
) {
if (!Filesystem::isValidPath($path)) {
return false;
}
$path = $this->getRelativePath($absolutePath);
if ($path == null) {
return false;
}
$this->verifyPath(dirname($path), basename($path));
if (in_array('write', $hooks) || in_array('delete', $hooks) || in_array('read', $hooks)) {
// always a shared lock during pre-hooks so the hook can read the file
$this->lockFile($path, ILockingProvider::LOCK_SHARED);
@ -1218,7 +1214,6 @@ class View {
} else {
$this->unlockFile($path, ILockingProvider::LOCK_SHARED);
}
}
return null;
}

@ -539,6 +539,20 @@ class Util {
\OCP\Server::get(LoggerInterface::class)->error('Invalid system config value for "blacklisted_files" is ignored.');
$invalidFilenames = ['.htaccess'];
}
if ($config->getSystemValueBool('enforce_windows_compatibility', false)) {
$invalidFilenames = array_merge(
$invalidFilenames,
[
"CON", "PRN", "AUX", "NUL", "COM0",
"COM1", "COM2", "COM3", "COM4", "COM5",
"COM6", "COM7", "COM8", "COM9", "COM¹",
"COM²", "COM³", "LPT0", "LPT1", "LPT2",
"LPT3", "LPT4", "LPT5", "LPT6", "LPT7",
"LPT8", "LPT9", "LPT¹", "LPT²", "LPT³",
],
);
}
self::$invalidFilenames = $invalidFilenames;
}
return self::$invalidFilenames;
@ -563,6 +577,17 @@ class Util {
$invalidChars = [];
}
// If windows compatibility is enabled we also forbidd reserved win32 API characters
if ($config->getSystemValueBool('enforce_windows_compatibility', false)) {
$invalidChars = array_merge(
$invalidChars,
// reserved characters of the win32 API
['<', '>', ':', '"', '|', '?', '*'],
// character 0-31 are also forbiden on windows but already filtered
// see IStorage::verifyPath
);
}
// Get admin defined invalid characters
$additionalChars = $config->getSystemValue('forbidden_chars', []);
if (!is_array($additionalChars)) {

Loading…
Cancel
Save