Skip to content
Open
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,14 @@ object AssetsInstallationHelper {
Files.createDirectories(destDir)
// Normalize and make destDir absolute for secure path validation
val normalizedDestDir = destDir.toAbsolutePath().normalize()
val realDestDir = normalizedDestDir.toRealPath()

// Zip entries are commonly clustered by directory (e.g. dozens of files
// under the same build-tools/<version>/ prefix); cache the last-verified
// parent so consecutive entries under it skip a redundant toRealPath() call.
// Nothing below can turn an already-verified real directory into a symlink
// mid-run, so caching by lexical parent equality is safe.
var lastVerifiedParent: Path? = null

ZipInputStream(srcStream.buffered()).useEntriesEach { zipInput, entry ->
// Validate entry name doesn't contain dangerous patterns
Expand All @@ -260,9 +268,28 @@ object AssetsInstallationHelper {
throw IllegalStateException("Entry is outside of the target dir: ${entry.name}")
}

// The checks above are lexical (entry name only) and don't catch a symlink
// already present on disk (e.g. destDir merged/reused across installer
// runs). Reject writing through an existing symlink up front, then
// re-check containment against the real, on-disk path once created.
if (Files.isSymbolicLink(destFile)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This symlink hardening lands in only 1 of 3 near-duplicate extraction sites. SplitAssetsInstaller.kt (~L170-190) and BundledAssetsInstaller.kt (~L178-198) each have a near-identical inline plugin-zip loop that still does only the lexical !targetPath.startsWith(pluginDirPath) check — no isSymbolicLink / toRealPath guard.

So either the on-disk-symlink threat is real, in which case those two loops are a matching gap and this defense belongs in a shared helper; or it isn't, in which case this is scope creep. Cleanest fix: have those two loops call extractZipToDir() instead of reimplementing it, so all three sites share one hardened path.

throw IllegalStateException("Refusing to extract over an existing symlink: ${entry.name}")
}

if (entry.isDirectory) {
Files.createDirectories(destFile)
if (!destFile.toRealPath().startsWith(realDestDir)) {
throw IllegalStateException("Entry escapes the target dir via symlink: ${entry.name}")
}
} else {
Files.createDirectories(destFile.parent)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (destFile.parent != lastVerifiedParent) {
if (!destFile.parent.toRealPath().startsWith(realDestDir)) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These toRealPath() escape checks have no test coverage. Every symlink test in ExtractZipToDirMergeTest presents the symlink as a directory entry — zipOf emits an ancestor directory entry, and rejects a bare directory entry… uses linked/ — so they all trip the leaf Files.isSymbolicLink(destFile) throw a few lines up first, and never reach the directory-branch check or this file-branch check. Deleting lines 279-291 would fail no test.

For the archive this PR actually fixes (android-sdk.zip has zero directory entries), this file-branch check is the only symlink defense that runs — and it's the untested one. Please add a test with a file entry under a pre-existing symlinked parent and no directory entry for that parent — e.g. build the zip manually with just linked/nested.txt, and pre-create dest/linked as a symlink escaping dest. As written, zipOf can't produce that shape because it always injects the linked/ directory entry.

throw IllegalStateException("Entry parent escapes the target dir via symlink: ${entry.name}")
}
lastVerifiedParent = destFile.parent
}

Files.newOutputStream(destFile).use { dest ->
zipInput.copyTo(dest)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,17 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkObject
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.FileNotFoundException
import java.nio.file.Files
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream

class AssetsInstallationHelperTest {
private val ctx: Context = mockk(relaxed = true)
Expand Down Expand Up @@ -48,4 +54,30 @@ class AssetsInstallationHelperTest {
(failure.cause?.cause) is FileNotFoundException,
)
}

@Test
fun `extractZipToDir creates parent directories for nested entries with no directory entries`() {
val destDir = Files.createTempDirectory("extract-zip-to-dir-test")
try {
val content = "test notice content"
val zipBytes =
ByteArrayOutputStream().use { baos ->
ZipOutputStream(baos).use { zos ->
// No directory entries, matching how android-sdk.zip is packaged.
zos.putNextEntry(ZipEntry("build-tools/35.0.0/NOTICE.txt"))
zos.write(content.toByteArray())
zos.closeEntry()
}
baos.toByteArray()
}

AssetsInstallationHelper.extractZipToDir(ByteArrayInputStream(zipBytes), destDir)

val extracted = destDir.resolve("build-tools/35.0.0/NOTICE.txt")
assertTrue("Expected extracted file to exist", Files.exists(extracted))
assertEquals(content, String(Files.readAllBytes(extracted)))
} finally {
destDir.toFile().deleteRecursively()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -63,11 +63,72 @@ class ExtractZipToDirMergeTest {
)
}

@Test
fun `extracts multiple sibling files under the same directory`() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Heads up: the zipOf comment above (lines 14-18) is now false. It says extractZipToDir "only Files.createDirectories() on directory entries, not on every file entry's parent" — but this PR's Files.createDirectories(destFile.parent) (in AssetsInstallationHelper.extractZipToDir) makes exactly that call. Please update the comment.

It also matters for coverage: because zipOf still injects a directory entry for every ancestor, this test — and the others built on zipOf — never exercises the no-directory-entry case that is the actual bug. Only AssetsInstallationHelperTest.`…nested entries with no directory entries` does.

val dest = Files.createTempDirectory("mvn")

AssetsInstallationHelper.extractZipToDir(
zipOf(
"com/foo/1.0/a.txt" to "a",
"com/foo/1.0/b.txt" to "b",
),
dest,
)

assertEquals("a", String(Files.readAllBytes(dest.resolve("com/foo/1.0/a.txt"))))
assertEquals("b", String(Files.readAllBytes(dest.resolve("com/foo/1.0/b.txt"))))
}

@Test
fun `rejects path traversal`() {
val dest = Files.createTempDirectory("mvn")
assertThrows(IllegalStateException::class.java) {
AssetsInstallationHelper.extractZipToDir(zipOf("../evil.jar" to "x"), dest)
}
}

@Test
fun `rejects extraction over an existing symlink`() {
val dest = Files.createTempDirectory("mvn")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

These symlink tests leak temp dirs, and set a data-loss trap for the next person. No test in this file has an @After/finally, so every run leaves the mvn*/outside* temp dirs behind — three of them (here, and the two linked cases below) containing symlinks that point outside the temp dir.

Harmless today only because nothing cleans up. But the sibling file already establishes dest.toFile().deleteRecursively() as the cleanup pattern (AssetsInstallationHelperTest), and Kotlin's File.deleteRecursively() follows symlinks — so copying that pattern here would recurse through dest/evil.jar / dest/linked and delete the target's contents. Please add cleanup that removes the links, not their targets (e.g. an @After that Files.deleteIfExists per created path, or walks with NOFOLLOW_LINKS).

val outsideTarget = Files.createTempDirectory("outside").resolve("payload")

Files.createSymbolicLink(dest.resolve("evil.jar"), outsideTarget)

assertThrows(IllegalStateException::class.java) {
AssetsInstallationHelper.extractZipToDir(zipOf("evil.jar" to "x"), dest)
}
}

@Test
fun `rejects extraction into a symlinked parent that escapes destDir`() {
val dest = Files.createTempDirectory("mvn")
val outside = Files.createTempDirectory("outside")

Files.createSymbolicLink(dest.resolve("linked"), outside)

assertThrows(IllegalStateException::class.java) {
AssetsInstallationHelper.extractZipToDir(zipOf("linked/nested.txt" to "x"), dest)
}
}

@Test
fun `rejects a bare directory entry that resolves to an existing symlink escaping destDir`() {
val dest = Files.createTempDirectory("mvn")
val outside = Files.createTempDirectory("outside")

Files.createSymbolicLink(dest.resolve("linked"), outside)

val zipBytes =
ByteArrayOutputStream().use { baos ->
ZipOutputStream(baos).use { zip ->
zip.putNextEntry(ZipEntry("linked/"))
zip.closeEntry()
}
baos.toByteArray()
}

assertThrows(IllegalStateException::class.java) {
AssetsInstallationHelper.extractZipToDir(ByteArrayInputStream(zipBytes), dest)
}
}
}
Loading