Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -228,8 +228,27 @@ public static String getFileName(String compressedName) {
}
}

public static final boolean isEntryPathValid(String entryPath) {
return !entryPath.startsWith("..\\") && !entryPath.startsWith("../") && !entryPath.equals("..");
public static boolean isEntryPathValid(String entryPath) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would mark valid paths that contain ".." as invalid. For example: dir1/dir2/.. is valid if dir1 is in the compressed file.

if (entryPath == null || entryPath.isEmpty()) {
return false;
}
// Normalize path separators to handle both Unix and Windows-style paths.
String normalized = entryPath.replace('\\', '/');
// Walk the path segments, tracking depth to detect escaping the archive root.
// A path like "dir/sub/.." is valid (it resolves to "dir"), but "../../evil" is not.
int depth = 0;
for (String segment : normalized.split("/")) {
if ("..".equals(segment)) {
depth--;
if (depth < 0) {
// Path would escape the archive root.
return false;
}
} else if (!segment.isEmpty() && !".".equals(segment)) {
depth++;
}
}
return true;
}

private static boolean isZip(String type) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.SEPARATOR;
import static com.amaze.filemanager.filesystem.compressed.CompressedHelper.SEPARATOR_CHAR;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
Expand Down Expand Up @@ -113,6 +114,27 @@ protected String fixEntryName(String entryName) {
}
}

/**
* Verifies that {@code outputFile} is contained within {@code outputDir}, guarding against
* zip-slip / path-traversal attacks.
*
* @throws IOException if the resolved canonical path of {@code outputFile} would land outside
* {@code outputDir}.
*/
protected static void checkEntryPath(File outputFile, String outputDir) throws IOException {
String canonicalOutput = outputFile.getCanonicalPath();
String canonicalDir = new File(outputDir).getCanonicalPath() + File.separator;
if (!canonicalOutput.startsWith(canonicalDir)
&& !canonicalOutput.equals(new File(outputDir).getCanonicalPath())) {
throw new IOException(
"Refusing to extract entry '"
+ outputFile.getName()
+ "' outside target directory '"
+ canonicalDir
+ "'");
}
}

public static class EmptyArchiveNotice extends IOException {}

public static class BadArchiveNotice extends IOException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ abstract class AbstractCommonsArchiveExtractor(
}
}
}
if (archiveEntries.size > 0) {
if (archiveEntries.isNotEmpty()) {
listener.onStart(totalBytes, archiveEntries[0].name)
inputStream.close()
inputStream = createFrom(FileInputStream(filePath))
Expand Down Expand Up @@ -96,11 +96,12 @@ abstract class AbstractCommonsArchiveExtractor(
entry: ArchiveEntry,
outputDir: String,
) {
val outputFile = File(outputDir, entry.name)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this code is repeated verbatim 3 times, is it possible to at least move the check to a function?

checkEntryPath(outputFile, outputDir)
if (entry.isDirectory) {
MakeDirectoryOperation.mkdir(File(outputDir, entry.name), context)
return
}
val outputFile = File(outputDir, entry.name)
if (false == outputFile.parentFile?.exists()) {
MakeDirectoryOperation.mkdir(outputFile.parentFile, context)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,12 +105,12 @@ class SevenZipExtractor(
entry: SevenZArchiveEntry,
outputDir: String,
) {
val name = entry.name
val outputFile = File(outputDir, entry.name)
checkEntryPath(outputFile, outputDir)
if (entry.isDirectory) {
MakeDirectoryOperation.mkdir(File(outputDir, name), context)
MakeDirectoryOperation.mkdir(File(outputDir, entry.name), context)
return
}
val outputFile = File(outputDir, name)
if (!outputFile.parentFile.exists()) {
MakeDirectoryOperation.mkdir(outputFile.parentFile, context)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@
package com.amaze.filemanager.filesystem.compressed.extractcontents.helpers

import android.content.Context
import android.os.Build
import com.amaze.filemanager.R
import com.amaze.filemanager.application.AppConfig
import com.amaze.filemanager.fileoperations.filesystem.compressed.ArchivePasswordCache
Expand All @@ -47,8 +46,6 @@ class ZipExtractor(
listener: OnUpdate,
updatePosition: UpdatePosition,
) : Extractor(context, filePath, outputPath, listener, updatePosition) {
private val isRobolectricTest = Build.HARDWARE == "robolectric"

@Throws(IOException::class)
override fun extractWithFilter(filter: Filter) {
var totalBytes: Long = 0
Expand Down Expand Up @@ -110,11 +107,7 @@ class ZipExtractor(
outputDir: String,
) {
val outputFile = File(outputDir, fixEntryName(entry.fileName))
if (!outputFile.canonicalPath.startsWith(outputDir) &&
(isRobolectricTest && !outputFile.canonicalPath.startsWith("/private$outputDir"))
) {
throw IOException("Incorrect ZipEntry path!")
}
checkEntryPath(outputFile, outputDir)
if (entry.isDirectory) {
// zip entry is a directory, return after creating new directory
MakeDirectoryOperation.mkdir(outputFile, context)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -77,9 +77,11 @@ class RarExtractor(
MainHeaderNullException::class.java.isAssignableFrom(it::class.java) -> {
throw BadArchiveNotice(it)
}

UnsupportedRarV5Exception::class.java.isAssignableFrom(it::class.java) -> {
throw it
}

else -> {
throw PasswordRequiredException(filePath)
}
Expand Down Expand Up @@ -144,11 +146,7 @@ class RarExtractor(
CompressedHelper.SEPARATOR,
)
val outputFile = File(outputDir, name)
if (!outputFile.canonicalPath.startsWith(outputDir) &&
(isRobolectricTest && !outputFile.canonicalPath.startsWith("/private$outputDir"))
) {
throw IOException("Incorrect RAR FileHeader path!")
}
checkEntryPath(outputFile, outputDir)
if (entry.isDirectory) {
MakeDirectoryOperation.mkdir(outputFile, context)
outputFile.setLastModified(entry.mTime.time)
Expand Down Expand Up @@ -232,7 +230,12 @@ class RarExtractor(
"\\\\".toRegex(),
CompressedHelper.SEPARATOR,
)
extractEntry(context, archive, header, context.externalCacheDir!!.absolutePath)
extractEntry(
context,
archive,
header,
context.externalCacheDir!!.absolutePath,
)
return "${context.externalCacheDir!!.absolutePath}/$filename"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -279,4 +279,46 @@ public void getFileNameTest() throws Exception {
// no path
assertEquals("", CompressedHelper.getFileName(""));
}

/**
* isEntryPathValid() tests.
*
* <p>Validates that paths which resolve within the archive root are accepted, and that paths
* which would escape the archive root are rejected — including cases where {@code ..} components
* appear in the middle of an otherwise valid path.
*/
@Test
public void isEntryPathValidTest() {
// null / empty are always invalid
assertFalse(CompressedHelper.isEntryPathValid(null));
assertFalse(CompressedHelper.isEntryPathValid(""));

// simple valid paths
assertTrue(CompressedHelper.isEntryPathValid("test.txt"));
assertTrue(CompressedHelper.isEntryPathValid("dir/file.txt"));
assertTrue(CompressedHelper.isEntryPathValid("dir/sub/file.txt"));
assertTrue(CompressedHelper.isEntryPathValid("dir/"));

// ".." that resolves back inside the root is VALID
// e.g. "dir/sub/.." resolves to "dir" — still inside the archive
assertTrue(CompressedHelper.isEntryPathValid("dir/sub/.."));
assertTrue(CompressedHelper.isEntryPathValid("dir/sub/../file.txt"));
assertTrue(CompressedHelper.isEntryPathValid("a/b/c/../../file.txt"));

// paths that escape the archive root are INVALID
assertFalse(CompressedHelper.isEntryPathValid("../evil.txt"));
assertFalse(CompressedHelper.isEntryPathValid("../../evil.txt"));
assertFalse(CompressedHelper.isEntryPathValid("foo/../../evil.txt"));
assertFalse(CompressedHelper.isEntryPathValid("foo/../../../evil.txt"));

// Windows-style separators are normalised first
assertFalse(CompressedHelper.isEntryPathValid("..\\evil.txt"));
assertFalse(CompressedHelper.isEntryPathValid("foo\\..\\..\\evil.txt"));
assertTrue(CompressedHelper.isEntryPathValid("dir\\sub\\file.txt"));
assertTrue(CompressedHelper.isEntryPathValid("dir\\sub\\..\\file.txt"));

// "." segments are ignored (current directory)
assertTrue(CompressedHelper.isEntryPathValid("./test.txt"));
assertTrue(CompressedHelper.isEntryPathValid("dir/./file.txt"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,66 @@

package com.amaze.filemanager.filesystem.compressed.extractcontents

import android.os.Environment
import androidx.test.core.app.ApplicationProvider
import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil
import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.SevenZipExtractor
import org.junit.Assert.assertFalse
import org.junit.Assert.fail
import org.junit.Test
import java.io.File
import java.io.IOException

open class SevenZipExtractorTest : AbstractArchiveExtractorTest() {
override val archiveType: String = "7z"

override fun extractorClass(): Class<out Extractor?> = SevenZipExtractor::class.java

/**
* Verify that a 7-Zip archive carrying a path-traversal entry
* (../POC_7Z_PROOF.txt) is blocked by the canonical-path guard:
* - extractEverything() must throw IOException
* - no file is written outside the designated output directory
*/
@Test
fun testExtractMalicious7z() {
val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.7z")
val outputDir = Environment.getExternalStorageDirectory()
val extractor =
SevenZipExtractor(
ApplicationProvider.getApplicationContext(),
maliciousArchive.absolutePath,
outputDir.absolutePath,
object : Extractor.OnUpdate {
override fun onStart(
totalBytes: Long,
firstEntryName: String,
) = Unit

override fun onUpdate(entryPath: String) = Unit

override fun isCancelled(): Boolean = false

override fun onFinish() = Unit
},
ServiceWatcherUtil.UPDATE_POSITION,
)

try {
extractor.extractEverything()
fail("Expected IOException: canonical-path guard must reject the traversal entry")
} catch (e: IOException) {
// Confirm the guard fired (not a generic bad-archive error)
assertFalse(
"Exception must not be a BadArchiveNotice",
e is Extractor.BadArchiveNotice,
)
}

// The malicious file must NOT have been written outside the output directory
assertFalse(
"Malicious file must not escape the output directory",
File(outputDir.parentFile, "POC_7Z_PROOF.txt").exists(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,67 @@

package com.amaze.filemanager.filesystem.compressed.extractcontents

import android.os.Environment
import androidx.test.core.app.ApplicationProvider
import com.amaze.filemanager.asynchronous.management.ServiceWatcherUtil
import com.amaze.filemanager.filesystem.compressed.extractcontents.helpers.TarGzExtractor
import org.junit.Assert.assertFalse
import org.junit.Assert.fail
import org.junit.Test
import java.io.File
import java.io.IOException

open class TarGzExtractorTest : AbstractArchiveExtractorTest() {
override val archiveType: String = "tar.gz"

override fun extractorClass(): Class<out Extractor?> = TarGzExtractor::class.java

/**
* Verify that a tar.gz archive carrying a path-traversal entry
* (../POC_ZIPSLIP_PROOF.txt) is blocked by the canonical-path guard
* in AbstractCommonsArchiveExtractor:
* - extractEverything() must throw IOException
* - no file is written outside the designated output directory
*/
@Test
fun testExtractMaliciousTarGz() {
val maliciousArchive = File(Environment.getExternalStorageDirectory(), "malicious.tar.gz")
val outputDir = Environment.getExternalStorageDirectory()
val extractor =
TarGzExtractor(
ApplicationProvider.getApplicationContext(),
maliciousArchive.absolutePath,
outputDir.absolutePath,
object : Extractor.OnUpdate {
override fun onStart(
totalBytes: Long,
firstEntryName: String,
) = Unit

override fun onUpdate(entryPath: String) = Unit

override fun isCancelled(): Boolean = false

override fun onFinish() = Unit
},
ServiceWatcherUtil.UPDATE_POSITION,
)

try {
extractor.extractEverything()
fail("Expected IOException: canonical-path guard must reject the traversal entry")
} catch (e: IOException) {
// Confirm the guard fired (not a generic bad-archive error)
assertFalse(
"Exception must not be a BadArchiveNotice",
e is Extractor.BadArchiveNotice,
)
}

// The malicious file must NOT have been written outside the output directory
assertFalse(
"Malicious file must not escape the output directory",
File(outputDir.parentFile, "POC_ZIPSLIP_PROOF.txt").exists(),
)
}
}
Loading
Loading