Skip to content

Commit c8858d2

Browse files
authored
Fix a potential ZIP slip (#9098)
* Fix a potential ZIP slip * Fixing tests and zip slip beavhior * Format
1 parent b0c9bd5 commit c8858d2

File tree

4 files changed

+44
-15
lines changed

4 files changed

+44
-15
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
- `firebase emulator:start` use a default project `demo-no-project` if no project can be found. (#9072)
22
- `firebase init dataconnect` also supports bootstrapping flutter template. (#9084)
3+
- Fixed a vulnerability in `unzip` util where files could be written outside of the expected output directory.
34
- `firebase init dataconnect` confirms Cloud SQL provisioning. (#9095)

src/test/fixtures/zip-files/index.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,16 @@ const zipFixturesDir = __dirname;
44
const testDataDir = join(zipFixturesDir, "node-unzipper-testData");
55

66
export const ZIP_CASES = [
7-
"compressed-cp866",
8-
"compressed-directory-entry",
9-
"compressed-flags-set",
10-
"compressed-standard",
11-
"uncompressed",
12-
"zip-slip",
13-
"zip64",
14-
].map((name) => ({
7+
{ name: "compressed-cp866" },
8+
{ name: "compressed-directory-entry" },
9+
{ name: "compressed-flags-set" },
10+
{ name: "compressed-standard" },
11+
{ name: "uncompressed" },
12+
{ name: "zip-slip", wantErr: "a path outside of" },
13+
{ name: "zip64" },
14+
].map(({ name, wantErr }) => ({
1515
name,
1616
archivePath: join(testDataDir, name, "archive.zip"),
1717
inflatedDir: join(testDataDir, name, "inflated"),
18+
wantErr,
1819
}));

src/unzip.spec.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,21 @@ describe("unzip", () => {
1616
await fs.promises.rm(tempDir, { recursive: true });
1717
});
1818

19-
for (const { name, archivePath, inflatedDir } of ZIP_CASES) {
20-
it(`should unzip a zip file with ${name} case`, async () => {
21-
const unzipPath = path.join(tempDir, name);
22-
await unzip(archivePath, unzipPath);
19+
for (const { name, archivePath, inflatedDir, wantErr } of ZIP_CASES) {
20+
if (!wantErr) {
21+
it(`should unzip a zip file with ${name} case`, async () => {
22+
const unzipPath = path.join(tempDir, name);
23+
await unzip(archivePath, unzipPath);
2324

24-
const expectedSize = await calculateFolderSize(inflatedDir);
25-
expect(await calculateFolderSize(unzipPath)).to.eql(expectedSize);
26-
});
25+
const expectedSize = await calculateFolderSize(inflatedDir);
26+
expect(await calculateFolderSize(unzipPath)).to.eql(expectedSize);
27+
});
28+
} else {
29+
it(`should throw "${wantErr}" when reading a zip file with ${name} case`, async () => {
30+
const unzipPath = path.join(tempDir, name);
31+
expect(unzip(archivePath, unzipPath)).to.eventually.be.rejectedWith(wantErr);
32+
});
33+
}
2734
}
2835
});
2936

src/unzip.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ const extractEntriesFromBuffer = async (data: Buffer, outputDir: string): Promis
8585
entry.fileName = entry.fileName.replace(/\//g, path.sep);
8686

8787
const outputFilePath = path.normalize(path.join(outputDir, entry.fileName));
88+
// Don't allow traversal outside of outputDir
89+
if (!isChildDir(outputDir, outputFilePath)) {
90+
throw new FirebaseError(
91+
`ZIP contained an entry for ${outputFilePath}, a path outside of ${outputDir}`,
92+
);
93+
}
8894

8995
logger.debug(`[unzip] Processing entry: ${entry.fileName}`);
9096
if (entry.fileName.endsWith(path.sep)) {
@@ -117,6 +123,20 @@ const extractEntriesFromBuffer = async (data: Buffer, outputDir: string): Promis
117123
}
118124
};
119125

126+
function isChildDir(parentDir: string, potentialChild: string): boolean {
127+
try {
128+
// 1. Resolve and normalize both paths to absolute paths
129+
const resolvedParent = path.resolve(parentDir);
130+
const resolvedChild = path.resolve(potentialChild);
131+
// The child path must start with the parent path and not be the same path.
132+
return resolvedChild.startsWith(resolvedParent) && resolvedChild !== resolvedParent;
133+
} catch (error) {
134+
// If either path does not exist, an error will be thrown.
135+
// In this case, the potential child cannot be a subdirectory.
136+
return false;
137+
}
138+
}
139+
120140
export const unzip = async (inputPath: string, outputDir: string): Promise<void> => {
121141
const data = await fs.promises.readFile(inputPath);
122142
await extractEntriesFromBuffer(data, outputDir);

0 commit comments

Comments
 (0)