Commit dd5cf4cc758 for nodejs
commit dd5cf4cc7588c55490750f7973695d613a27c635
Author: Trivikram Kamat <trivikr.dev@gmail.com>
Date: Tue Sep 22 07:24:21 2026 -0700
vfs: validate ZipProvider parent directories
Reject attempts to create archive entries beneath non-directory
parents. Cover open, mkdir, and rename in the sync and async APIs.
Signed-off-by: Kamat, Trivikram <16024985+trivikr@users.noreply.github.com>
Assisted-by: codex
PR-URL: https://github.com/nodejs/node/pull/65829
Fixes: https://github.com/nodejs/node/issues/65828
Reviewed-By: James M Snell <jasnell@gmail.com>
diff --git a/lib/internal/vfs/providers/ziparchive.js b/lib/internal/vfs/providers/ziparchive.js
index 9ed3a64a4e0..23ac09cad51 100644
--- a/lib/internal/vfs/providers/ziparchive.js
+++ b/lib/internal/vfs/providers/ziparchive.js
@@ -361,6 +361,24 @@ class ZipProvider extends VirtualProvider {
return false;
}
+ /**
+ * Throws when an ancestor of `name` is a file. Missing ancestors are valid:
+ * ZIP archives represent directories implicitly when they contain entries
+ * beneath them.
+ * @param {string} name
+ * @param {string} syscall
+ * @param {string} path
+ */
+ #validateParentDirectories(name, syscall, path) {
+ let slash = StringPrototypeIndexOf(name, '/');
+ while (slash !== -1) {
+ if (this.#source.has(StringPrototypeSlice(name, 0, slash))) {
+ throw createENOTDIR(syscall, path);
+ }
+ slash = StringPrototypeIndexOf(name, '/', slash + 1);
+ }
+ }
+
async open(path, flags, mode) {
const name = normalize(path);
const fileEntry = await this.#getEntry(name);
@@ -377,6 +395,7 @@ class ZipProvider extends VirtualProvider {
if (!exists && mustExist(flags)) {
throw createENOENT('open', path);
}
+ if (!exists) this.#validateParentDirectories(name, 'open', path);
let initial = EMPTY_BUFFER;
if (exists && !isWriteTruncate(flags)) {
initial = await fileEntry.content();
@@ -400,6 +419,7 @@ class ZipProvider extends VirtualProvider {
if (!exists && mustExist(flags)) {
throw createENOENT('open', path);
}
+ if (!exists) this.#validateParentDirectories(name, 'open', path);
let initial = EMPTY_BUFFER;
if (exists && !isWriteTruncate(flags)) {
initial = fileEntry.contentSync();
@@ -482,6 +502,7 @@ class ZipProvider extends VirtualProvider {
async mkdir(path, options) {
if (this.readonly) throw createEROFS('mkdir', path);
const name = normalize(path);
+ this.#validateParentDirectories(name, 'mkdir', path);
if (await this.exists(path)) {
// `{ recursive: true }` only tolerates an existing *directory*; an
// existing file (or any non-directory) still collides with EEXIST.
@@ -494,6 +515,7 @@ class ZipProvider extends VirtualProvider {
mkdirSync(path, options) {
if (this.readonly) throw createEROFS('mkdir', path);
const name = normalize(path);
+ this.#validateParentDirectories(name, 'mkdir', path);
if (this.existsSync(path)) {
// `{ recursive: true }` only tolerates an existing *directory*; an
// existing file (or any non-directory) still collides with EEXIST.
@@ -574,6 +596,7 @@ class ZipProvider extends VirtualProvider {
entries = [{ oldName, newName, entry }];
}
if (oldName === newName) return;
+ this.#validateParentDirectories(newName, 'rename', newPath);
for (let i = 0; i < entries.length; i++) {
const item = entries[i];
@@ -602,6 +625,7 @@ class ZipProvider extends VirtualProvider {
entries = [{ oldName, newName, entry }];
}
if (oldName === newName) return;
+ this.#validateParentDirectories(newName, 'rename', newPath);
for (let i = 0; i < entries.length; i++) {
const item = entries[i];
diff --git a/test/parallel/test-vfs-zip-provider.js b/test/parallel/test-vfs-zip-provider.js
index 843a606a5de..ef86e6cb6a4 100644
--- a/test/parallel/test-vfs-zip-provider.js
+++ b/test/parallel/test-vfs-zip-provider.js
@@ -83,6 +83,24 @@ async function buildArchive(entries, comment) {
assert.strictEqual(await archiveVfs.promises.readFile('/new.txt', 'utf8'), 'brand new');
assert.strictEqual(zip.has('new.txt'), true);
+ // Entries cannot be created beneath a file.
+ await assert.rejects(
+ archiveVfs.promises.writeFile('/a.txt/child.txt', 'child'),
+ { code: 'ENOTDIR' },
+ );
+ await assert.rejects(
+ archiveVfs.promises.mkdir('/a.txt/child'),
+ { code: 'ENOTDIR' },
+ );
+ await assert.rejects(
+ archiveVfs.promises.rename('/new.txt', '/a.txt/renamed.txt'),
+ { code: 'ENOTDIR' },
+ );
+ assert.strictEqual(zip.has('a.txt/child.txt'), false);
+ assert.strictEqual(zip.has('a.txt/child/'), false);
+ assert.strictEqual(zip.has('a.txt/renamed.txt'), false);
+ assert.strictEqual(zip.has('new.txt'), true);
+
// Overwriting an existing file.
await archiveVfs.promises.writeFile('/a.txt', 'overwritten');
assert.strictEqual(await archiveVfs.promises.readFile('/a.txt', 'utf8'), 'overwritten');
@@ -226,6 +244,24 @@ async function buildArchive(entries, comment) {
archiveVfs.appendFileSync('/new.txt', '!');
assert.strictEqual(archiveVfs.readFileSync('/new.txt', 'utf8'), 'brand new!');
+ // Entries cannot be created beneath a file.
+ assert.throws(
+ () => archiveVfs.writeFileSync('/a.txt/child.txt', 'child'),
+ { code: 'ENOTDIR' },
+ );
+ assert.throws(
+ () => archiveVfs.mkdirSync('/a.txt/child'),
+ { code: 'ENOTDIR' },
+ );
+ assert.throws(
+ () => archiveVfs.renameSync('/new.txt', '/a.txt/renamed.txt'),
+ { code: 'ENOTDIR' },
+ );
+ assert.strictEqual(zip.has('a.txt/child.txt'), false);
+ assert.strictEqual(zip.has('a.txt/child/'), false);
+ assert.strictEqual(zip.has('a.txt/renamed.txt'), false);
+ assert.strictEqual(zip.has('new.txt'), true);
+
// mkdir/rmdir.
archiveVfs.mkdirSync('/newdir');
assert.strictEqual(archiveVfs.statSync('/newdir').isDirectory(), true);