diff --git a/src/libsync/foldermetadata.cpp b/src/libsync/foldermetadata.cpp index 3cd3e2c8705ff..adb96a017023b 100644 --- a/src/libsync/foldermetadata.cpp +++ b/src/libsync/foldermetadata.cpp @@ -9,6 +9,7 @@ #include "clientsideencryption.h" #include "clientsideencryptionjobs.h" #include +#include #include #include #include @@ -49,6 +50,27 @@ QString metadataStringFromOCsDocument(const QJsonDocument &ocsDoc) } } +bool FolderMetadata::isOriginalFilenameValid(const QString &originalFilename) +{ + if (originalFilename.isEmpty()) { + return false; + } + + if (originalFilename == QStringLiteral(".") + || originalFilename == QStringLiteral("..")) { + return false; + } + + if (originalFilename.contains(QLatin1Char('/')) + || originalFilename.contains(QLatin1Char('\\')) + || originalFilename.contains(QChar(0))) { + return false; + } + + const auto slashPrefixedName = QStringLiteral("/") + originalFilename; + return QDir::cleanPath(slashPrefixedName) == slashPrefixedName; +} + bool FolderMetadata::EncryptedFile::isDirectory() const { return mimetype.isEmpty() || mimetype == QByteArrayLiteral("inode/directory") || mimetype == QByteArrayLiteral("httpd/unix-directory"); @@ -121,6 +143,11 @@ void FolderMetadata::setupExistingMetadata(const QByteArray &metadata) _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); return; } + if (_existingMetadataVersion < MetadataVersion::Version2_0 && !_initialSignature.isEmpty()) { + qCWarning(lcCseMetadata()) << "Could not setup legacy metadata with a V2 signature."; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return; + } if (_existingMetadataVersion < MetadataVersion::Version2_0) { setupExistingMetadataLegacy(metadata); return; @@ -250,22 +277,39 @@ void FolderMetadata::setupExistingMetadata(const QByteArray &metadata) _counter = counterVariantFromJson.value(); } + QVector parsedFiles; + parsedFiles.reserve(files.size() + folders.size()); + for (auto it = files.constBegin(), end = files.constEnd(); it != end; ++it) { const auto parsedEncryptedFile = parseEncryptedFileFromJson(it.key(), it.value()); - if (!parsedEncryptedFile.originalFilename.isEmpty()) { - _files.push_back(parsedEncryptedFile); + if (parsedEncryptedFile.originalFilename.isEmpty()) { + qCWarning(lcCseMetadata()) << "Could not setup metadata. Encrypted file" << it.key() << "metadata is invalid."; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return; } + parsedFiles.push_back(parsedEncryptedFile); } for (auto it = folders.constBegin(); it != folders.constEnd(); ++it) { const auto folderName = it.value().toString(); - if (!folderName.isEmpty()) { - EncryptedFile file; - file.encryptedFilename = it.key(); - file.originalFilename = folderName; - _files.push_back(file); + if (folderName.isEmpty()) { + qCWarning(lcCseMetadata()) << "Could not setup metadata. Encrypted folder" << it.key() << "metadata has an empty file name."; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return; } + + if (!isOriginalFilenameValid(folderName)) { + qCWarning(lcCseMetadata()) << "Could not setup metadata. Encrypted folder" << it.key() << "metadata has an invalid file name."; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return; + } + + EncryptedFile file; + file.encryptedFilename = it.key(); + file.originalFilename = folderName; + parsedFiles.push_back(file); } + _files = parsedFiles; _isMetadataValid = true; } @@ -349,12 +393,20 @@ void FolderMetadata::setupExistingMetadataLegacy(const QByteArray &metadata) const auto decryptedFileObj = decryptedFileDoc.object(); - if (decryptedFileObj["filename"].toString().isEmpty()) { - qCWarning(lcCseMetadata) << "decrypted metadata" << decryptedFileDoc.toJson(QJsonDocument::Compact) << "skipping encrypted file" << file.encryptedFilename << "metadata has an empty file name"; - continue; + const auto originalFilename = decryptedFileObj["filename"].toString(); + if (originalFilename.isEmpty()) { + qCWarning(lcCseMetadata) << "decrypted metadata" << decryptedFileDoc.toJson(QJsonDocument::Compact) << "Could not setup legacy metadata. Encrypted file" << file.encryptedFilename << "metadata has an empty file name."; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return; + } + + if (!isOriginalFilenameValid(originalFilename)) { + qCWarning(lcCseMetadata) << "Could not setup legacy metadata. Encrypted file" << file.encryptedFilename << "metadata has an invalid file name."; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return; } - file.originalFilename = decryptedFileObj["filename"].toString(); + file.originalFilename = originalFilename; file.encryptionKey = QByteArray::fromBase64(decryptedFileObj["key"].toString().toLocal8Bit()); file.mimetype = decryptedFileObj["mimetype"].toString().toLocal8Bit(); @@ -508,10 +560,17 @@ bool FolderMetadata::isValid() const FolderMetadata::EncryptedFile FolderMetadata::parseEncryptedFileFromJson(const QString &encryptedFilename, const QJsonValue &fileJSON) const { const auto fileObj = fileJSON.toObject(); - if (fileObj["filename"].toString().isEmpty()) { + const auto originalFilename = fileObj["filename"].toString(); + if (originalFilename.isEmpty()) { qCWarning(lcCseMetadata()) << "skipping encrypted file" << encryptedFilename << "metadata has an empty file name"; return {}; } + + if (!isOriginalFilenameValid(originalFilename)) { + qCWarning(lcCseMetadata()) << "skipping encrypted file" << encryptedFilename << "metadata has an invalid file name"; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return {}; + } EncryptedFile file; file.encryptedFilename = encryptedFilename; @@ -521,7 +580,7 @@ FolderMetadata::EncryptedFile FolderMetadata::parseEncryptedFileFromJson(const Q nonce = QByteArray::fromBase64(fileObj[nonceKey].toString().toLocal8Bit()); } file.initializationVector = nonce; - file.originalFilename = fileObj["filename"].toString(); + file.originalFilename = originalFilename; file.encryptionKey = QByteArray::fromBase64(fileObj["key"].toString().toLocal8Bit()); file.mimetype = fileObj["mimetype"].toString().toLocal8Bit(); @@ -535,6 +594,11 @@ FolderMetadata::EncryptedFile FolderMetadata::parseEncryptedFileFromJson(const Q QJsonObject FolderMetadata::convertFileToJsonObject(const EncryptedFile *encryptedFile) const { + if (!encryptedFile || !isOriginalFilenameValid(encryptedFile->originalFilename)) { + qCWarning(lcCseMetadata()) << "Metadata generation failed. Invalid original file name."; + return {}; + } + QJsonObject file; file.insert("key", QString(encryptedFile->encryptionKey.toBase64())); file.insert("filename", encryptedFile->originalFilename); @@ -728,6 +792,12 @@ QByteArray FolderMetadata::encryptedMetadataLegacy() QJsonObject files; for (auto it = _files.constBegin(), end = _files.constEnd(); it != end; ++it) { + if (!isOriginalFilenameValid(it->originalFilename)) { + qCWarning(lcCseMetadata) << "Metadata generation failed. Invalid original file name for encrypted file" << it->encryptedFilename; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return {}; + } + QJsonObject encrypted; encrypted.insert("key", QString(it->encryptionKey.toBase64())); encrypted.insert("filename", it->originalFilename); @@ -919,11 +989,17 @@ QByteArray FolderMetadata::prepareMetadataForSignature(const QJsonDocument &full return metdataModified.toJson(QJsonDocument::Compact); } -void FolderMetadata::addEncryptedFile(const EncryptedFile &f) { +bool FolderMetadata::addEncryptedFile(const EncryptedFile &f) { Q_ASSERT(_isMetadataValid); if (!_isMetadataValid) { qCWarning(lcCseMetadata()) << "Could not add encrypted file to non-initialized metadata!"; - return; + return false; + } + + if (!isOriginalFilenameValid(f.originalFilename)) { + qCWarning(lcCseMetadata()) << "Could not add encrypted file with invalid original file name."; + _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); + return false; } for (int i = 0; i < _files.size(); ++i) { @@ -934,6 +1010,7 @@ void FolderMetadata::addEncryptedFile(const EncryptedFile &f) { } _files.append(f); + return true; } const QByteArray FolderMetadata::binaryMetadataKeyForDecryption() const @@ -1006,7 +1083,9 @@ bool FolderMetadata::moveFromFileDropToFiles() _account->reportClientStatus(OCC::ClientStatusReportingStatus::E2EeError_GeneralError); return false; } - addEncryptedFile(parsedEncryptedFile); + if (!addEncryptedFile(parsedEncryptedFile)) { + return false; + } } _fileDropEntries.clear(); diff --git a/src/libsync/foldermetadata.h b/src/libsync/foldermetadata.h index 496f118edb19a..56c5d91819dfa 100644 --- a/src/libsync/foldermetadata.h +++ b/src/libsync/foldermetadata.h @@ -147,7 +147,7 @@ class OWNCLOUDSYNC_EXPORT FolderMetadata : public QObject static MetadataVersion setupVersionFromExistingMetadata(const QByteArray &metadata); public slots: - void addEncryptedFile(const OCC::FolderMetadata::EncryptedFile &f); + [[nodiscard]] bool addEncryptedFile(const OCC::FolderMetadata::EncryptedFile &f); void removeEncryptedFile(const OCC::FolderMetadata::EncryptedFile &f); void removeAllEncryptedFiles(); @@ -172,6 +172,8 @@ public slots: [[nodiscard]] QJsonObject convertFileToJsonObject(const EncryptedFile *encryptedFile) const; + [[nodiscard]] static bool isOriginalFilenameValid(const QString &originalFilename); + [[nodiscard]] MetadataVersion latestSupportedMetadataVersion() const; [[nodiscard]] bool parseFileDropPart(const QJsonDocument &doc); diff --git a/src/libsync/propagateremotemkdir.cpp b/src/libsync/propagateremotemkdir.cpp index 70d498ddd2a54..737374f28413f 100644 --- a/src/libsync/propagateremotemkdir.cpp +++ b/src/libsync/propagateremotemkdir.cpp @@ -210,7 +210,11 @@ void PropagateRemoteMkdir::slotMkdir() connect(_uploadEncryptedHelper, &PropagateUploadEncrypted::finalized, this, &PropagateRemoteMkdir::slotStartEncryptedMkcolJob); connect(_uploadEncryptedHelper, &PropagateUploadEncrypted::error, - []{ qCWarning(lcPropagateRemoteMkdir) << "Error setting up encryption."; }); + this, [this] { + qCWarning(lcPropagateRemoteMkdir) << "Error setting up encryption."; + propagator()->_activeJobList.removeOne(this); + done(SyncFileItem::FatalError, tr("Failed to create encrypted folder."), ErrorCategory::GenericError); + }); _uploadEncryptedHelper->start(); } diff --git a/src/libsync/propagateuploadencrypted.cpp b/src/libsync/propagateuploadencrypted.cpp index 212abba35ee98..11ff3472f0129 100644 --- a/src/libsync/propagateuploadencrypted.cpp +++ b/src/libsync/propagateuploadencrypted.cpp @@ -163,7 +163,11 @@ void PropagateUploadEncrypted::slotFetchMetadataJobFinished(int statusCode, cons qCDebug(lcPropagateUploadEncrypted) << "Creating the metadata for the encrypted file."; - metadata->addEncryptedFile(encryptedFile); + if (!metadata->addEncryptedFile(encryptedFile)) { + qCWarning(lcPropagateUploadEncrypted()) << "There was an error encrypting the file, aborting upload. Invalid metadata file name."; + emit error(); + return; + } qCDebug(lcPropagateUploadEncrypted) << "Metadata created, sending to the server."; diff --git a/test/testclientsideencryptionv2.cpp b/test/testclientsideencryptionv2.cpp index faf394a8924b7..c72ef28d50bba 100644 --- a/test/testclientsideencryptionv2.cpp +++ b/test/testclientsideencryptionv2.cpp @@ -104,7 +104,7 @@ private slots: encryptedFile.originalFilename = fakeFileName; encryptedFile.mimetype = "application/octet-stream"; encryptedFile.initializationVector = EncryptionHelper::generateRandom(16); - metadata->addEncryptedFile(encryptedFile); + QVERIFY(metadata->addEncryptedFile(encryptedFile)); const auto encryptedMetadata = metadata->encryptedMetadata(); QVERIFY(!encryptedMetadata.isEmpty()); @@ -220,6 +220,156 @@ private slots: QVERIFY(!metadataFromJson->isValid()); } + void testOriginalFilenameValidation_data() + { + QTest::addColumn("originalFilename"); + QTest::addColumn("isValid"); + + QTest::newRow("plain file") << QStringLiteral("document.txt") << true; + QTest::newRow("plain folder") << QStringLiteral("Documents") << true; + QTest::newRow("hidden file") << QStringLiteral(".hidden") << true; + QTest::newRow("empty") << QString() << false; + QTest::newRow("current directory") << QStringLiteral(".") << false; + QTest::newRow("parent directory") << QStringLiteral("..") << false; + QTest::newRow("relative traversal") << QStringLiteral("../../poc_dir") << false; + QTest::newRow("embedded slash") << QStringLiteral("folder/file.txt") << false; + QTest::newRow("absolute path") << QStringLiteral("/tmp/poc") << false; + QTest::newRow("backslash") << QStringLiteral("folder\\file.txt") << false; + QTest::newRow("null byte") << QStringLiteral("file") + QChar(0) + QStringLiteral("name") << false; + } + + void testOriginalFilenameValidation() + { + QFETCH(QString, originalFilename); + QFETCH(bool, isValid); + + QCOMPARE(FolderMetadata::isOriginalFilenameValid(originalFilename), isValid); + } + + void testParseEncryptedFileFromJsonRejectsUnsafeOriginalFilename_data() + { + testOriginalFilenameValidation_data(); + } + + void testParseEncryptedFileFromJsonRejectsUnsafeOriginalFilename() + { + QFETCH(QString, originalFilename); + QFETCH(bool, isValid); + + QScopedPointer metadata(new FolderMetadata(_account, "/", FolderMetadata::FolderType::Root)); + QSignalSpy metadataSetupCompleteSpy(metadata.data(), &FolderMetadata::setupComplete); + metadataSetupCompleteSpy.wait(); + QCOMPARE(metadataSetupCompleteSpy.count(), 1); + QVERIFY(metadata->isValid()); + + const auto fileJson = QJsonObject{ + {QStringLiteral("filename"), originalFilename}, + {QStringLiteral("key"), QString::fromUtf8(QByteArrayLiteral("key").toBase64())}, + {QStringLiteral("mimetype"), QStringLiteral("application/octet-stream")}, + {QStringLiteral("nonce"), QString::fromUtf8(QByteArrayLiteral("nonce").toBase64())}, + {QStringLiteral("authenticationTag"), QString::fromUtf8(QByteArrayLiteral("tag").toBase64())}, + }; + + const auto parsedEncryptedFile = metadata->parseEncryptedFileFromJson(QStringLiteral("encrypted-name"), fileJson); + QCOMPARE(!parsedEncryptedFile.originalFilename.isEmpty(), isValid); + if (isValid) { + QCOMPARE(parsedEncryptedFile.originalFilename, originalFilename); + } + } + + void testAddEncryptedFileRejectsUnsafeOriginalFilename() + { + QScopedPointer metadata(new FolderMetadata(_account, "/", FolderMetadata::FolderType::Root)); + QSignalSpy metadataSetupCompleteSpy(metadata.data(), &FolderMetadata::setupComplete); + metadataSetupCompleteSpy.wait(); + QCOMPARE(metadataSetupCompleteSpy.count(), 1); + QVERIFY(metadata->isValid()); + + FolderMetadata::EncryptedFile encryptedFile; + encryptedFile.encryptionKey = EncryptionHelper::generateRandom(16); + encryptedFile.encryptedFilename = EncryptionHelper::generateRandomFilename(); + encryptedFile.originalFilename = QStringLiteral("folder\\file.txt"); + encryptedFile.mimetype = "application/octet-stream"; + encryptedFile.initializationVector = EncryptionHelper::generateRandom(16); + + QVERIFY(!metadata->addEncryptedFile(encryptedFile)); + QVERIFY(metadata->files().isEmpty()); + } + + void testSetupExistingMetadataRejectsUnsafeOriginalFilename() + { + QScopedPointer metadata(new FolderMetadata(_account, "/", FolderMetadata::FolderType::Root)); + QSignalSpy metadataSetupCompleteSpy(metadata.data(), &FolderMetadata::setupComplete); + metadataSetupCompleteSpy.wait(); + QCOMPARE(metadataSetupCompleteSpy.count(), 1); + QVERIFY(metadata->isValid()); + + const auto initialEncryptedMetadata = metadata->encryptedMetadata(); + QVERIFY(!initialEncryptedMetadata.isEmpty()); + + const auto encryptedFilename = QStringLiteral("encrypted-name"); + const auto fileJson = QJsonObject{ + {QStringLiteral("filename"), QStringLiteral("folder\\file.txt")}, + {QStringLiteral("key"), QString::fromUtf8(EncryptionHelper::generateRandom(16).toBase64())}, + {QStringLiteral("mimetype"), QStringLiteral("application/octet-stream")}, + {QStringLiteral("nonce"), QString::fromUtf8(EncryptionHelper::generateRandom(16).toBase64())}, + {QStringLiteral("authenticationTag"), QString::fromUtf8(EncryptionHelper::generateRandom(16).toBase64())}, + }; + + QJsonArray keyChecksums; + for (auto it = metadata->_keyChecksums.constBegin(), end = metadata->_keyChecksums.constEnd(); it != end; ++it) { + keyChecksums.push_back(QJsonValue::fromVariant(*it)); + } + + const auto cipherText = QJsonObject{ + {QStringLiteral("counter"), QJsonValue::fromVariant(metadata->newCounter())}, + {QStringLiteral("files"), QJsonObject{{encryptedFilename, fileJson}}}, + {QStringLiteral("folders"), QJsonObject{}}, + {QStringLiteral("keyChecksums"), keyChecksums}, + }; + const auto cipherTextDoc = QJsonDocument(cipherText); + + QByteArray authenticationTag; + const auto nonce = EncryptionHelper::generateRandom(16); + const auto encryptedCipherText = EncryptionHelper::gzipThenEncryptData(metadata->binaryMetadataKeyForEncryption(), + cipherTextDoc.toJson(QJsonDocument::Compact), + nonce, + authenticationTag).toBase64() + + QByteArrayLiteral("|") + nonce.toBase64(); + + auto metadataDoc = QJsonDocument::fromJson(initialEncryptedMetadata); + auto metaObject = metadataDoc.object(); + auto metadataObject = metaObject[QStringLiteral("metadata")].toObject(); + metadataObject.insert(QStringLiteral("ciphertext"), QString::fromUtf8(encryptedCipherText)); + metadataObject.insert(QStringLiteral("nonce"), QString::fromUtf8(nonce.toBase64())); + metadataObject.insert(QStringLiteral("authenticationTag"), QString::fromUtf8(authenticationTag.toBase64())); + metaObject.insert(QStringLiteral("metadata"), metadataObject); + metadataDoc.setObject(metaObject); + + const auto signature = _account->e2e()->generateSignatureCryptographicMessageSyntax(FolderMetadata::prepareMetadataForSignature(metadataDoc).toBase64()).toBase64(); + QVERIFY(!signature.isEmpty()); + + const auto ocsDoc = QJsonDocument(QJsonObject{ + {QStringLiteral("ocs"), QJsonObject{ + {QStringLiteral("data"), QJsonObject{ + {QStringLiteral("meta-data"), QString::fromUtf8(metadataDoc.toJson(QJsonDocument::Compact))}, + }}, + }}, + }); + + QScopedPointer metadataFromJson(new FolderMetadata(_account, + "/", + ocsDoc.toJson(), + RootEncryptedFolderInfo::makeDefault(), + signature, + FolderMetadata::FolderType::Root)); + QSignalSpy metadataSetupExistingCompleteSpy(metadataFromJson.data(), &FolderMetadata::setupComplete); + metadataSetupExistingCompleteSpy.wait(); + QCOMPARE(metadataSetupExistingCompleteSpy.count(), 1); + QVERIFY(!metadataFromJson->isValid()); + QVERIFY(metadataFromJson->files().isEmpty()); + } + void testE2EeFolderMetadataSharing() { // instantiate empty metadata, add a file, and share with a second user "sharee" @@ -237,7 +387,7 @@ private slots: encryptedFile.originalFilename = fakeFileName; encryptedFile.mimetype = "application/octet-stream"; encryptedFile.initializationVector = EncryptionHelper::generateRandom(16); - metadata->addEncryptedFile(encryptedFile); + QVERIFY(metadata->addEncryptedFile(encryptedFile)); QVERIFY(metadata->addUser(_secondAccount->davUser(), _secondAccount->e2e()->getCertificate(), FolderMetadata::CertificateType::SoftwareNextcloudCertificate)); @@ -328,7 +478,7 @@ private slots: encryptedFile.originalFilename = fakeFileNameFromSecondUser; encryptedFile.mimetype = "application/octet-stream"; encryptedFile.initializationVector = EncryptionHelper::generateRandom(16); - metadataFromJsonForSecondUser->addEncryptedFile(encryptedFile); + QVERIFY(metadataFromJsonForSecondUser->addEncryptedFile(encryptedFile)); auto encryptedMetadataFromSecondUser = metadataFromJsonForSecondUser->encryptedMetadata(); encryptedMetadataFromSecondUser.replace("\"", "\\\""); diff --git a/test/testsecurefiledrop.cpp b/test/testsecurefiledrop.cpp index bcae215bdb307..b8daeabb7b7c9 100644 --- a/test/testsecurefiledrop.cpp +++ b/test/testsecurefiledrop.cpp @@ -87,7 +87,7 @@ private slots: encryptedFile.originalFilename = fakeFileName; encryptedFile.mimetype = "application/octet-stream"; encryptedFile.initializationVector = EncryptionHelper::generateRandom(16); - metadata->addEncryptedFile(encryptedFile); + QVERIFY(metadata->addEncryptedFile(encryptedFile)); } QJsonObject fakeFileDropPart;