-
-
Notifications
You must be signed in to change notification settings - Fork 37
ADFA-5005: Fix SDK bootstrap crash extracting android-sdk.zip #1621
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: stage
Are you sure you want to change the base?
Changes from all commits
250db83
60f124a
cf98828
c287d5b
ff234ef
dbc21bb
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
|
|
@@ -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)) { | ||
| 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) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if (destFile.parent != lastVerifiedParent) { | ||
| if (!destFile.parent.toRealPath().startsWith(realDestDir)) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. These For the archive this PR actually fixes ( |
||
| throw IllegalStateException("Entry parent escapes the target dir via symlink: ${entry.name}") | ||
| } | ||
| lastVerifiedParent = destFile.parent | ||
| } | ||
|
|
||
| Files.newOutputStream(destFile).use { dest -> | ||
| zipInput.copyTo(dest) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -63,11 +63,72 @@ class ExtractZipToDirMergeTest { | |
| ) | ||
| } | ||
|
|
||
| @Test | ||
| fun `extracts multiple sibling files under the same directory`() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Heads up: the It also matters for coverage: because |
||
| 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") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 Harmless today only because nothing cleans up. But the sibling file already establishes |
||
| 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) | ||
| } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
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) andBundledAssetsInstaller.kt(~L178-198) each have a near-identical inline plugin-zip loop that still does only the lexical!targetPath.startsWith(pluginDirPath)check — noisSymbolicLink/toRealPathguard.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.