diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 7731e32f1f4..46cbf5f87ba 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -973,7 +973,10 @@ class ChatActivity : return@launch } - val file = File(context.cacheDir, filename) + val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, filename) + if (file == null) { + return@launch + } if (file.exists()) { if (isCurrentlyPlaying) { chatViewModel.pauseMediaPlayer(true) @@ -1929,7 +1932,10 @@ class ChatActivity : private fun setUpWaveform(message: ChatMessage, thenPlay: Boolean = true, backgroundPlayAllowed: Boolean = false) { val filename = message.fileParameters.name - val file = File(context.cacheDir, filename!!) + val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, filename) + if (file == null) { + return + } if (file.exists() && message.voiceMessageFloatArray == null) { message.isDownloadingVoiceMessage = true chatViewModel.syncVoiceMessageUiState(message) @@ -2537,7 +2543,12 @@ class ChatActivity : if (cursor != null && cursor.moveToFirst()) { val id = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID)) val fileName = ContactUtils.getDisplayNameFromDeviceContact(context, id) + ".vcf" - val file = File(context.cacheDir, fileName) + val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, fileName) + if (file == null) { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + cursor.close() + return + } writeContactToVcfFile(cursor, file) val shareUri = FileProvider.getUriForFile( @@ -4039,12 +4050,16 @@ class ChatActivity : } fun share(message: ChatMessage) { - val filename = message.fileParameters.name - path = applicationContext.cacheDir.absolutePath + "/" + filename + val sharedFile = FileUtils.resolveSharedAttachmentFile(applicationContext.cacheDir, message.fileParameters.name) + if (sharedFile == null) { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return + } + path = sharedFile.absolutePath val shareUri = FileProvider.getUriForFile( this, BuildConfig.APPLICATION_ID, - File(path) + sharedFile ) val shareIntent: Intent = Intent().apply { @@ -4057,9 +4072,12 @@ class ChatActivity : } fun checkIfSharable(message: ChatMessage) { - val filename = message.fileParameters.name - path = applicationContext.cacheDir.absolutePath + "/" + filename - val file = File(context.cacheDir, filename!!) + val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, message.fileParameters.name) + if (file == null) { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return + } + path = file.absolutePath if (file.exists()) { share(message) } else { @@ -4080,9 +4098,12 @@ class ChatActivity : } fun checkIfSaveable(message: ChatMessage) { - val filename = message.fileParameters.name - path = applicationContext.cacheDir.absolutePath + "/" + filename - val file = File(context.cacheDir, filename!!) + val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, message.fileParameters.name) + if (file == null) { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return + } + path = file.absolutePath if (file.exists()) { showSaveToStorageWarning(message) } else { @@ -4112,12 +4133,16 @@ class ChatActivity : var metaData = "" var objectId = "" if (message.hasFileAttachment) { - val filename = message.fileParameters.name - path = applicationContext.cacheDir.absolutePath + "/" + filename + val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, message.fileParameters.name) + if (file == null) { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return@launch + } + path = file.absolutePath shareUri = FileProvider.getUriForFile( context, BuildConfig.APPLICATION_ID, - File(path) + file ) grantUriPermission( @@ -4342,14 +4367,15 @@ class ChatActivity : Intent(MediaStore.ACTION_VIDEO_CAPTURE).also { takeVideoIntent -> takeVideoIntent.resolveActivity(packageManager)?.also { val videoFile: File? = try { - val outputDir = context.cacheDir + val outputDir = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) + ?: throw IOException("Could not create shared attachments directory") val dateFormat = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT) val date = dateFormat.format(Date()) val videoName = String.format( context.resources.getString(R.string.nc_video_filename), date ) - File("$outputDir/$videoName$VIDEO_SUFFIX") + File(outputDir, "$videoName$VIDEO_SUFFIX") } catch (e: IOException) { Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() Log.e(TAG, "error while creating video file", e) diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt index def7a442c65..e2d7004a48e 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenImageActivity.kt @@ -12,6 +12,7 @@ package com.nextcloud.talk.fullscreenfile import android.content.Intent import android.os.Bundle +import android.util.Log import android.widget.FrameLayout import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.MaterialTheme @@ -35,6 +36,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.ui.SwipeToCloseLayout import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype.IMAGE_PREFIX_GENERIC import java.io.File import javax.inject.Inject @@ -48,6 +50,7 @@ class FullScreenImageActivity : AppCompatActivity() { private lateinit var windowInsetsController: WindowInsetsControllerCompat private lateinit var path: String private lateinit var fileName: String + private lateinit var imageFile: File private lateinit var swipeToCloseLayout: SwipeToCloseLayout private var showFullscreen by mutableStateOf(false) @@ -57,7 +60,12 @@ class FullScreenImageActivity : AppCompatActivity() { fileName = intent.getStringExtra("FILE_NAME").orEmpty() val isGif = intent.getBooleanExtra("IS_GIF", false) - path = applicationContext.cacheDir.absolutePath + "/" + fileName + imageFile = FileUtils.resolveSharedAttachmentFile(applicationContext.cacheDir, fileName) ?: run { + Log.e(TAG, "Invalid image filename: $fileName") + finish() + return + } + path = imageFile.absolutePath enableEdgeToEdge( statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), @@ -123,7 +131,7 @@ class FullScreenImageActivity : AppCompatActivity() { } private fun shareFile() { - val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, File(path)) + val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, imageFile) val shareIntent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_STREAM, shareUri) @@ -141,4 +149,8 @@ class FullScreenImageActivity : AppCompatActivity() { private fun showBitmapError() { Snackbar.make(swipeToCloseLayout, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() } + + companion object { + private val TAG = FullScreenImageActivity::class.java.simpleName + } } diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt index 7c089f5c781..bdaa2c23b61 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenMediaActivity.kt @@ -12,6 +12,7 @@ package com.nextcloud.talk.fullscreenfile import android.content.Intent import android.os.Bundle +import android.util.Log import android.view.WindowManager import android.widget.FrameLayout import androidx.activity.SystemBarStyle @@ -41,6 +42,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.ui.SwipeToCloseLayout import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype.VIDEO_PREFIX_GENERIC import java.io.File import javax.inject.Inject @@ -53,6 +55,7 @@ class FullScreenMediaActivity : AppCompatActivity() { private lateinit var path: String private lateinit var fileName: String + private lateinit var mediaFile: File private var player: ExoPlayer? by mutableStateOf(null) private var playWhenReadyState: Boolean = true private var playBackPosition: Long = 0L @@ -64,7 +67,12 @@ class FullScreenMediaActivity : AppCompatActivity() { fileName = intent.getStringExtra("FILE_NAME").orEmpty() val isAudioOnly = intent.getBooleanExtra("AUDIO_ONLY", false) - path = applicationContext.cacheDir.absolutePath + "/" + fileName + mediaFile = FileUtils.resolveSharedAttachmentFile(applicationContext.cacheDir, fileName) ?: run { + Log.e(TAG, "Invalid media filename: $fileName") + finish() + return + } + path = mediaFile.absolutePath enableEdgeToEdge( statusBarStyle = SystemBarStyle.dark(android.graphics.Color.TRANSPARENT), @@ -128,7 +136,7 @@ class FullScreenMediaActivity : AppCompatActivity() { } private fun preparePlayer() { - val mediaItem: MediaItem = MediaItem.fromUri(File(path).toUri()) + val mediaItem: MediaItem = MediaItem.fromUri(mediaFile.toUri()) player?.let { exoPlayer -> exoPlayer.setMediaItem(mediaItem) exoPlayer.playWhenReady = playWhenReadyState @@ -160,7 +168,7 @@ class FullScreenMediaActivity : AppCompatActivity() { } private fun shareFile() { - val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, File(path)) + val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, mediaFile) val shareIntent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_STREAM, shareUri) @@ -174,4 +182,8 @@ class FullScreenMediaActivity : AppCompatActivity() { val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(fileName) saveFragment.show(supportFragmentManager, SaveToStorageDialogFragment.TAG) } + + companion object { + private val TAG = FullScreenMediaActivity::class.java.simpleName + } } diff --git a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt index dc70b75de1e..8a4d9e432e6 100644 --- a/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/fullscreenfile/FullScreenTextViewerActivity.kt @@ -11,6 +11,7 @@ package com.nextcloud.talk.fullscreenfile import android.content.ComponentName import android.content.Intent import android.os.Bundle +import android.util.Log import androidx.activity.compose.setContent import androidx.appcompat.app.AppCompatActivity import androidx.compose.material3.MaterialTheme @@ -25,6 +26,7 @@ import com.nextcloud.talk.components.ColoredStatusBar import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.utils.AccountUtils.canWeOpenFilesApp +import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.Mimetype.TEXT_PREFIX_GENERIC import com.nextcloud.talk.utils.adjustUIForAPILevel35 import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACCOUNT @@ -37,6 +39,7 @@ class FullScreenTextViewerActivity : AppCompatActivity() { @Inject lateinit var viewThemeUtils: ViewThemeUtils + private lateinit var textFile: File override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -48,8 +51,12 @@ class FullScreenTextViewerActivity : AppCompatActivity() { val link = intent.getStringExtra("LINK") val username = intent.getStringExtra("USERNAME").orEmpty() val baseUrl = intent.getStringExtra("BASE_URL").orEmpty() - val path = applicationContext.cacheDir.absolutePath + "/" + fileName - val text = readFile(path) + textFile = FileUtils.resolveSharedAttachmentFile(applicationContext.cacheDir, fileName) ?: run { + Log.e(TAG, "Invalid text filename: $fileName") + finish() + return + } + val text = readFile(textFile) adjustUIForAPILevel35() @@ -62,7 +69,7 @@ class FullScreenTextViewerActivity : AppCompatActivity() { text = text, isMarkdown = isMarkdown, actions = FullScreenTextActions( - onShare = { shareFile(path) }, + onShare = { shareFile() }, onSave = { showSaveDialog(fileName) }, onOpenInFilesApp = if (fileId.isNotEmpty()) { { openInFilesApp(link, fileId, username, baseUrl) } @@ -94,8 +101,8 @@ class FullScreenTextViewerActivity : AppCompatActivity() { } } - private fun shareFile(path: String) { - val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, File(path)) + private fun shareFile() { + val shareUri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, textFile) val shareIntent = Intent().apply { action = Intent.ACTION_SEND putExtra(Intent.EXTRA_STREAM, shareUri) @@ -110,5 +117,9 @@ class FullScreenTextViewerActivity : AppCompatActivity() { saveFragment.show(supportFragmentManager, SaveToStorageDialogFragment.TAG) } - private fun readFile(fileName: String) = File(fileName).inputStream().readBytes().toString(Charsets.UTF_8) + private fun readFile(file: File) = file.inputStream().readBytes().toString(Charsets.UTF_8) + + companion object { + private val TAG = FullScreenTextViewerActivity::class.java.simpleName + } } diff --git a/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt index 74f877da617..82858b2fa79 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/DownloadFileToCacheWorker.kt @@ -18,6 +18,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.users.UserManager import com.nextcloud.talk.utils.ApiUtils +import com.nextcloud.talk.utils.FileUtils import com.nextcloud.talk.utils.database.user.CurrentUserProviderOld import com.nextcloud.talk.utils.preferences.AppPreferences import okhttp3.ResponseBody @@ -88,15 +89,21 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa } private fun executeDownload(body: ResponseBody?, fileName: String): Result { - if (body == null) { - Log.e(TAG, "Response body when downloading $fileName is null!") + val targetFile = FileUtils.resolveSharedAttachmentFile(context.cacheDir, fileName) + if (body == null || targetFile == null) { + if (body == null) { + Log.e(TAG, "Response body when downloading $fileName is null!") + } + if (targetFile == null) { + Log.e(TAG, "Refused to download file with unsafe name: $fileName") + } return Result.failure() } var count: Int val data = ByteArray(BYTE_UNIT_DIVIDER * DATA_BYTES) val bis: InputStream = BufferedInputStream(body.byteStream(), BYTE_UNIT_DIVIDER * DOWNLOAD_STREAM_SIZE) - val outputFile = File(context.cacheDir, fileName + "_") + val outputFile = File(targetFile.parentFile, targetFile.name + "_") val output: OutputStream = FileOutputStream(outputFile) var total: Long = 0 val startTime = System.currentTimeMillis() @@ -122,20 +129,16 @@ class DownloadFileToCacheWorker(val context: Context, workerParameters: WorkerPa output.close() bis.close() - return onDownloadComplete(fileName) + return onDownloadComplete(outputFile, targetFile) } - private fun onDownloadComplete(fileName: String): Result { - val tempFile = File(context.cacheDir, fileName + "_") - val targetFile = File(context.cacheDir, fileName) - - return if (tempFile.renameTo(targetFile)) { + private fun onDownloadComplete(tempFile: File, targetFile: File): Result = + if (tempFile.renameTo(targetFile)) { setProgressAsync(Data.Builder().putBoolean(SUCCESS, true).build()) Result.success() } else { Result.failure() } - } companion object { const val TAG = "DownloadFileToCache" diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt index 39e7aa8e374..fd8cde273c8 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt @@ -25,6 +25,7 @@ import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication import com.nextcloud.talk.jobs.SaveFileToStorageWorker import com.nextcloud.talk.ui.theme.ViewThemeUtils +import com.nextcloud.talk.utils.FileUtils import java.util.concurrent.ExecutionException import javax.inject.Inject @@ -70,7 +71,10 @@ class SaveToStorageDialogFragment : DialogFragment() { @SuppressLint("LongLogTag") private fun saveImageToStorage(fileName: String) { - val sourceFilePath = requireContext().cacheDir.path + val sourceDirectory = FileUtils.getSharedAttachmentsDirectory(requireContext().cacheDir) ?: run { + Log.e(TAG, "Failed to resolve shared attachments directory") + return + } val workerTag = SAVE_TO_STORAGE_WORKER_PREFIX + fileName val workers = WorkManager.getInstance(requireContext()).getWorkInfosByTag(workerTag) @@ -88,7 +92,7 @@ class SaveToStorageDialogFragment : DialogFragment() { val data: Data = Data.Builder() .putString(SaveFileToStorageWorker.KEY_FILE_NAME, fileName) - .putString(SaveFileToStorageWorker.KEY_SOURCE_FILE_PATH, "$sourceFilePath/$fileName") + .putString(SaveFileToStorageWorker.KEY_SOURCE_FILE_PATH, "$sourceDirectory/$fileName") .build() val saveWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(SaveFileToStorageWorker::class.java) diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt index db967d4480e..ee9382e6b8d 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileUtils.kt @@ -26,6 +26,52 @@ object FileUtils { private val TAG = FileUtils::class.java.simpleName private const val RADIX: Int = 16 private const val MD5_LENGTH: Int = 32 + private const val SHARED_ATTACHMENTS_DIRECTORY = "shared_attachments" + + /** + * Returns the dedicated cache directory used for externally shared attachments. + */ + fun getSharedAttachmentsDirectory(cacheDir: File): File? { + val directory = File(cacheDir, SHARED_ATTACHMENTS_DIRECTORY) + if (directory.exists()) { + return if (directory.isDirectory) directory else null + } + return if (directory.mkdirs()) directory else null + } + + /** + * Resolves a shared attachment file inside the dedicated cache directory. + */ + fun resolveSharedAttachmentFile(cacheDir: File, untrustedFileName: String?): File? { + val sharedDirectory = getSharedAttachmentsDirectory(cacheDir) ?: return null + return resolveFileInDirectory(sharedDirectory, untrustedFileName) + } + + /** + * Resolves an untrusted file name inside [baseDirectory] and rejects traversal attempts. + */ + fun resolveFileInDirectory(baseDirectory: File, untrustedFileName: String?): File? { + val fileName = untrustedFileName?.trim().orEmpty() + val isSimpleFileName = fileName.isNotEmpty() && + fileName != "." && + fileName != ".." && + !fileName.contains('/') && + !fileName.contains('\\') && + File(fileName).name == fileName + + val resolvedFile = if (isSimpleFileName) { + val canonicalBase = baseDirectory.canonicalFile + val candidate = File(canonicalBase, fileName).canonicalFile + if (candidate.path.startsWith(canonicalBase.path + File.separator)) { + candidate + } else { + null + } + } else { + null + } + return resolvedFile + } /** * Creates a new [File] @@ -33,7 +79,11 @@ object FileUtils { @Suppress("ThrowsCount") @JvmStatic fun getTempCacheFile(context: Context, fileName: String): File { - val cacheFile = File(context.applicationContext.filesDir.absolutePath + "/" + fileName) + val cacheRoot = context.applicationContext.cacheDir.canonicalFile + val cacheFile = File(cacheRoot, fileName).canonicalFile + if (!cacheFile.path.startsWith(cacheRoot.path + File.separator)) { + throw IOException("Temporary file must stay inside cache directory.") + } Log.v(TAG, "Full path for new cache file:" + cacheFile.absolutePath) val tempDir = cacheFile.parentFile ?: throw FileNotFoundException("could not cacheFile.getParentFile()") if (!tempDir.exists()) { @@ -60,7 +110,11 @@ object FileUtils { * Creates a new [File] */ fun removeTempCacheFile(context: Context, fileName: String) { - val cacheFile = File(context.applicationContext.filesDir.absolutePath + "/" + fileName) + val cacheRoot = context.applicationContext.cacheDir.canonicalFile + val cacheFile = File(cacheRoot, fileName).canonicalFile + if (!cacheFile.path.startsWith(cacheRoot.path + File.separator)) { + throw IOException("Temporary file must stay inside cache directory.") + } Log.v(TAG, "Full path for new cache file:" + cacheFile.absolutePath) if (cacheFile.exists()) { if (cacheFile.delete()) { diff --git a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt index f35ea277d6f..7fb4397f5bb 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/FileViewerUtils.kt @@ -51,7 +51,6 @@ import com.nextcloud.talk.utils.MimetypeUtils.isGif import com.nextcloud.talk.utils.MimetypeUtils.isMarkdown import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ACCOUNT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FILE_ID -import java.io.File import java.util.concurrent.ExecutionException /* @@ -88,20 +87,28 @@ class FileViewerUtils(private val context: Context, private val user: User) { openWhenDownloadState: MutableState, downloadState: MutableState>? = null ) { - if (isSupportedForInternalViewer(fileInfo.mimetype) || - canBeHandledByExternalApp(fileInfo.mimetype, fileInfo.fileName) + val safeFile = FileUtils.resolveSharedAttachmentFile(context.cacheDir, fileInfo.fileName) + if (safeFile == null) { + Log.e(TAG, "Refused to open file with unsafe name: ${fileInfo.fileName}") + Snackbar.make(View(context), R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return + } + val safeFileInfo = fileInfo.copy(fileName = safeFile.name) + + if (isSupportedForInternalViewer(safeFileInfo.mimetype) || + canBeHandledByExternalApp(safeFileInfo.mimetype, safeFileInfo.fileName) ) { openOrDownloadFile( - fileInfo, + safeFileInfo, openWhenDownloadState, downloadState ) - } else if (!fileInfo.link.isNullOrEmpty()) { - openFileInFilesApp(fileInfo.link, fileInfo.fileId) + } else if (!safeFileInfo.link.isNullOrEmpty()) { + openFileInFilesApp(safeFileInfo.link, safeFileInfo.fileId) } else { Log.e( TAG, - "File with id " + fileInfo.fileId + " can't be opened because internal viewer doesn't " + + "File with id " + safeFileInfo.fileId + " can't be opened because internal viewer doesn't " + "support it, it can't be handled by an external app and there is no link " + "to open it in the nextcloud files app" ) @@ -110,8 +117,7 @@ class FileViewerUtils(private val context: Context, private val user: User) { } private fun canBeHandledByExternalApp(mimetype: String?, fileName: String): Boolean { - val path: String = context.cacheDir.absolutePath + "/" + fileName - val file = File(path) + val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, fileName) ?: return false val intent = Intent(Intent.ACTION_VIEW) intent.setDataAndType(Uri.fromFile(file), mimetype) return intent.resolveActivity(context.packageManager) != null @@ -122,7 +128,12 @@ class FileViewerUtils(private val context: Context, private val user: User) { openWhenDownloadState: MutableState, downloadState: MutableState>? ) { - val file = File(context.cacheDir, fileInfo.fileName) + val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, fileInfo.fileName) + if (file == null) { + Log.e(TAG, "Refused to open file with unsafe name: ${fileInfo.fileName}") + Snackbar.make(View(context), R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return + } if (file.exists()) { openFileByMimetype(fileInfo.fileName, fileInfo.mimetype, fileInfo.link, fileInfo.fileId) } else { @@ -163,8 +174,12 @@ class FileViewerUtils(private val context: Context, private val user: User) { @Suppress("Detekt.TooGenericExceptionCaught") private fun openFileByExternalApp(fileName: String, mimetype: String) { - val path = context.cacheDir.absolutePath + "/" + fileName - val file = File(path) + val file = FileUtils.resolveSharedAttachmentFile(context.cacheDir, fileName) + if (file == null) { + Log.e(TAG, "Refused to share file with unsafe name: $fileName") + Snackbar.make(View(context), R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return + } val intent = Intent() intent.action = Intent.ACTION_VIEW val pdfURI = FileProvider.getUriForFile(context, context.packageName, file) diff --git a/app/src/main/res/xml/file_provider_paths.xml b/app/src/main/res/xml/file_provider_paths.xml index 5cab30c98c7..4b7d2df81a4 100644 --- a/app/src/main/res/xml/file_provider_paths.xml +++ b/app/src/main/res/xml/file_provider_paths.xml @@ -5,10 +5,7 @@ ~ SPDX-License-Identifier: GPL-3.0-or-later --> - + name="shared_attachments" + path="shared_attachments/" /> diff --git a/app/src/test/java/com/nextcloud/talk/utils/FileUtilsTest.kt b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsTest.kt new file mode 100644 index 00000000000..0797e0f3fb7 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/utils/FileUtilsTest.kt @@ -0,0 +1,135 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.utils + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class FileUtilsTest { + + @Test + fun resolveFileInDirectory_acceptsSimpleFileName() { + val baseDir = createTempDir() + try { + val result = FileUtils.resolveFileInDirectory(baseDir, "report.pdf") + + assertNotNull(result) + assertEquals(File(baseDir, "report.pdf").canonicalPath, result?.canonicalPath) + } finally { + baseDir.deleteRecursively() + } + } + + @Test + fun resolveFileInDirectory_rejectsTraversal() { + val baseDir = createTempDir() + try { + val result = FileUtils.resolveFileInDirectory(baseDir, "../settings.preferences_pb") + + assertNull(result) + } finally { + baseDir.deleteRecursively() + } + } + + @Test + fun resolveFileInDirectory_rejectsNestedPath() { + val baseDir = createTempDir() + try { + val result = FileUtils.resolveFileInDirectory(baseDir, "nested/file.txt") + + assertNull(result) + } finally { + baseDir.deleteRecursively() + } + } + + @Test + fun resolveFileInDirectory_rejectsBackslashPath() { + val baseDir = createTempDir() + try { + val result = FileUtils.resolveFileInDirectory(baseDir, "nested\\file.txt") + + assertNull(result) + } finally { + baseDir.deleteRecursively() + } + } + + @Test + fun resolveFileInDirectory_rejectsBlank() { + val baseDir = createTempDir() + try { + val result = FileUtils.resolveFileInDirectory(baseDir, "") + + assertNull(result) + } finally { + baseDir.deleteRecursively() + } + } + + @Test + fun getSharedAttachmentsDirectory_createsDedicatedSubDirectory() { + val baseDir = createTempDir() + try { + val sharedDirectory = FileUtils.getSharedAttachmentsDirectory(baseDir) + + assertNotNull(sharedDirectory) + assertTrue(sharedDirectory!!.exists()) + assertTrue(sharedDirectory.isDirectory) + assertEquals("shared_attachments", sharedDirectory.name) + } finally { + baseDir.deleteRecursively() + } + } + + @Test + fun resolveSharedAttachmentFile_resolvesInsideDedicatedSubDirectory() { + val baseDir = createTempDir() + try { + val result = FileUtils.resolveSharedAttachmentFile(baseDir, "example.pdf") + + assertNotNull(result) + val expected = File(baseDir, "shared_attachments/example.pdf").canonicalPath + assertEquals(expected, result?.canonicalPath) + } finally { + baseDir.deleteRecursively() + } + } + + @Test + fun resolveSharedAttachmentFile_rejectsTraversal() { + val baseDir = createTempDir() + try { + val result = FileUtils.resolveSharedAttachmentFile(baseDir, "../settings.preferences_pb") + + assertNull(result) + } finally { + baseDir.deleteRecursively() + } + } + + @Test + fun getSharedAttachmentsDirectory_returnsNullWhenPathIsFile() { + val baseDir = createTempDir() + try { + File(baseDir, "shared_attachments").writeText("not a directory") + + val result = FileUtils.getSharedAttachmentsDirectory(baseDir) + + assertNull(result) + } finally { + baseDir.deleteRecursively() + } + } + + private fun createTempDir(): File = kotlin.io.path.createTempDirectory("file-utils-test-").toFile() +}