From e6fb3326d94304d295369bf67a8f1318bbfa1006 Mon Sep 17 00:00:00 2001 From: Khoi Nguyen Date: Tue, 20 Oct 2026 15:22:10 -0700 Subject: [PATCH 1/2] Rebuild dist folder to include all dep upgrades since the last time it was built --- dist/index.js | 1853 ++++++++++++++++++++++++++++++++------------- dist/index.js.map | 2 +- 2 files changed, 1341 insertions(+), 514 deletions(-) diff --git a/dist/index.js b/dist/index.js index 5a3063ad..d983263c 100644 --- a/dist/index.js +++ b/dist/index.js @@ -63842,6 +63842,18 @@ module.exports = function (/**String*/ input, /** object */ options) { // instanciate utils filesystem const filetools = new Utils(opts); + // Restore the archived permissions on extracted directories. This has to run + // after a directory's contents are written: applying a restrictive mode + // (e.g. 0o500) up front would stop us writing the files it contains. Applying + // the deepest paths first keeps parent directories traversable while their + // children are updated (issue #530). + const applyDirAttributes = (dirEntries) => { + dirEntries + .filter((d) => d.attr) + .sort((a, b) => b.path.length - a.path.length) + .forEach((d) => filetools.fs.chmodSync(d.path, d.attr)); + }; + if (typeof opts.decoder !== "object" || typeof opts.decoder.encode !== "function" || typeof opts.decoder.decode !== "function") { opts.decoder = Utils.decoder; } @@ -63881,7 +63893,7 @@ module.exports = function (/**String*/ input, /** object */ options) { function fixPath(zipPath) { const { join, normalize, sep } = pth.posix; // convert windows file separators and normalize - return join(".", normalize(sep + zipPath.split("\\").join(sep) + sep)); + return join(pth.isAbsolute(zipPath) ? "/": '.', normalize(sep + zipPath.split("\\").join(sep) + sep)); } function filenameFilter(filterfn) { @@ -63996,6 +64008,7 @@ module.exports = function (/**String*/ input, /** object */ options) { * Remove the entry from the file or the entry and all it's nested directories and files if the given entry is a directory * * @param {ZipEntry|string} entry + * @param {boolean} withsubfolders * @returns {void} */ deleteFile: function (entry, withsubfolders = true) { @@ -64280,7 +64293,11 @@ module.exports = function (/**String*/ input, /** object */ options) { addLocalFolderAsync2: function (options, callback) { const self = this; options = typeof options === "object" ? options : { localPath: options }; - localPath = pth.resolve(fixPath(options.localPath)); + // Resolve the local filesystem path with the platform resolver. Do NOT + // run it through fixPath: that normalizes ZIP-internal paths to POSIX + // and prepends "/", which turns a Windows path like C:\dir into + // \C:\dir, so readdir finds nothing and the archive comes out empty. + const localPath = pth.resolve(options.localPath); let { zipPath, filter, namefix } = options; if (filter instanceof RegExp) { @@ -64299,7 +64316,7 @@ module.exports = function (/**String*/ input, /** object */ options) { zipPath = zipPath ? fixPath(zipPath) : ""; // Check Namefix function - if (namefix == "latin1") { + if (namefix === "latin1") { namefix = (str) => str .normalize("NFD") @@ -64315,14 +64332,21 @@ module.exports = function (/**String*/ input, /** object */ options) { filetools.fs.open(localPath, "r", function (err) { if (err && err.code === "ENOENT") { - callback(undefined, Utils.Errors.FILE_NOT_FOUND(localPath)); + // callback is (err, done); errors belong in the first argument, + // otherwise addLocalFolderPromise treats the error as "done" and + // resolves instead of rejecting. + callback(Utils.Errors.FILE_NOT_FOUND(localPath), false); } else if (err) { - callback(undefined, err); + callback(err, false); } else { filetools.findFilesAsync(localPath, function (err, fileEntries) { - if (err) return callback(err); + if (err) return callback(err, false); fileEntries = fileEntries.filter((dir) => filter(relPathFix(dir))); - if (!fileEntries.length) callback(undefined, false); + // Nothing to add (empty folder or everything filtered out) is a + // success, not an error. Report done and stop, otherwise the + // reduce below runs and the callback fires a second time -- and + // signalling done=false left addLocalFolderPromise hanging. + if (!fileEntries.length) return callback(undefined, true); setImmediate( fileEntries.reverse().reduce(function (next, entry) { @@ -64357,7 +64381,7 @@ module.exports = function (/**String*/ input, /** object */ options) { addLocalFolderPromise: function (localPath, props) { return new Promise((resolve, reject) => { this.addLocalFolderAsync2(Object.assign({ localPath }, props), (err, done) => { - if (err) reject(err); + if (err) return reject(err); if (done) resolve(this); }); }); @@ -64475,7 +64499,7 @@ module.exports = function (/**String*/ input, /** object */ options) { var entryName = canonical(item.entryName); - var target = sanitize(targetPath, outFileName && !item.isDirectory ? outFileName : maintainEntryPath ? entryName : pth.basename(entryName)); + var target = sanitize(targetPath, outFileName && !item.isDirectory ? canonical(outFileName) : maintainEntryPath ? entryName : pth.basename(entryName)); if (item.isDirectory) { var children = _zip.getEntryChildren(item); @@ -64485,8 +64509,14 @@ module.exports = function (/**String*/ input, /** object */ options) { if (!content) { throw Utils.Errors.CANT_EXTRACT_FILE(); } - var name = canonical(child.entryName); - var childName = sanitize(targetPath, maintainEntryPath ? name : pth.basename(name)); + // When not maintaining the full entry path, keep each child's path + // relative to the extracted directory (drop the directory's own + // prefix) instead of flattening every file to its basename, which + // collapsed subdirectories together (issue #306). + var name = canonical(maintainEntryPath ? child.entryName : child.entryName.substring(item.entryName.length)); + var childName = sanitize(targetPath, name); + // reject writing through a pre-existing symlink inside the target + filetools.assertPathSafe(targetPath, childName); // The reverse operation for attr depend on method addFile() const fileAttr = keepOriginalPermission ? child.header.fileAttr : undefined; filetools.writeFileTo(childName, content, overwrite, fileAttr); @@ -64497,6 +64527,9 @@ module.exports = function (/**String*/ input, /** object */ options) { var content = item.getData(_zip.password); if (!content) throw Utils.Errors.CANT_EXTRACT_FILE(); + // reject writing through a pre-existing symlink inside the target + filetools.assertPathSafe(targetPath, target); + if (filetools.fs.existsSync(target) && !overwrite) { throw Utils.Errors.CANT_OVERRIDE(); } @@ -64516,12 +64549,14 @@ module.exports = function (/**String*/ input, /** object */ options) { return false; } - for (var entry in _zip.entries) { + for (var entry of _zip.entries) { try { if (entry.isDirectory) { continue; } - var content = _zip.entries[entry].getData(pass); + // was `_zip.entries[entry]` (indexing the array with an entry + // object -> undefined -> threw -> test() always returned false) + var content = entry.getData(pass); if (!content) { return false; } @@ -64548,10 +64583,15 @@ module.exports = function (/**String*/ input, /** object */ options) { overwrite = get_Bool(false, overwrite); if (!_zip) throw Utils.Errors.NO_ZIP(); + const dirEntries = []; _zip.entries.forEach(function (entry) { var entryName = sanitize(targetPath, canonical(entry.entryName)); + // reject writing through a pre-existing symlink inside the target + filetools.assertPathSafe(targetPath, entryName); if (entry.isDirectory) { filetools.makeDir(entryName); + // defer restoring the directory permission until its files are written + if (keepOriginalPermission) dirEntries.push({ path: entryName, attr: entry.header.fileAttr }); return; } var content = entry.getData(pass); @@ -64562,11 +64602,15 @@ module.exports = function (/**String*/ input, /** object */ options) { const fileAttr = keepOriginalPermission ? entry.header.fileAttr : undefined; filetools.writeFileTo(entryName, content, overwrite, fileAttr); try { + // best-effort: an invalid date in the archive or a filesystem that + // rejects utimes must not fail extraction of already-written content (issue #379) filetools.fs.utimesSync(entryName, entry.header.time, entry.header.time); } catch (err) { - throw Utils.Errors.CANT_EXTRACT_FILE(); + /* ignore timestamp failures */ } }); + + applyDirAttributes(dirEntries); }, /** @@ -64617,20 +64661,43 @@ module.exports = function (/**String*/ input, /** object */ options) { // Create directory entries first synchronously // this prevents race condition and assures folders are there before writing files + const deferredDirAttr = []; for (const entry of dirEntries) { const dirPath = getPath(entry); // The reverse operation for attr depend on method addFile() const dirAttr = keepOriginalPermission ? entry.header.fileAttr : undefined; try { + // reject writing through a pre-existing symlink inside the target + filetools.assertPathSafe(targetPath, dirPath); filetools.makeDir(dirPath); - if (dirAttr) filetools.fs.chmodSync(dirPath, dirAttr); - // in unix timestamp will change if files are later added to folder, but still - filetools.fs.utimesSync(dirPath, entry.header.time, entry.header.time); } catch (er) { callback(getError("Unable to create folder", dirPath)); + continue; + } + // defer restoring the directory permission until its files are written: + // a restrictive mode applied now would block writing them + if (dirAttr) deferredDirAttr.push({ path: dirPath, attr: dirAttr }); + try { + // in unix timestamp will change if files are later added to folder, but still. + // best-effort: a utimes failure must not abort extraction (issue #379) + filetools.fs.utimesSync(dirPath, entry.header.time, entry.header.time); + } catch (er) { + /* ignore timestamp failures */ } } + // restore directory permissions once every file has been extracted + const done = (err) => { + if (!err) { + try { + applyDirAttributes(deferredDirAttr); + } catch (er) { + return callback(getError("Unable to set folder permissions", er.path || "")); + } + } + callback(err); + }; + fileEntries.reverse().reduce(function (next, entry) { return function (err) { if (err) { @@ -64638,6 +64705,12 @@ module.exports = function (/**String*/ input, /** object */ options) { } else { const entryName = pth.normalize(canonical(entry.entryName)); const filePath = sanitize(targetPath, entryName); + try { + // reject writing through a pre-existing symlink inside the target + filetools.assertPathSafe(targetPath, filePath); + } catch (er) { + return next(er); + } entry.getDataAsync(function (content, err_1) { if (err_1) { next(err_1); @@ -64648,21 +64721,19 @@ module.exports = function (/**String*/ input, /** object */ options) { const fileAttr = keepOriginalPermission ? entry.header.fileAttr : undefined; filetools.writeFileToAsync(filePath, content, overwrite, fileAttr, function (succ) { if (!succ) { - next(getError("Unable to write file", filePath)); + return next(getError("Unable to write file", filePath)); } - filetools.fs.utimes(filePath, entry.header.time, entry.header.time, function (err_2) { - if (err_2) { - next(getError("Unable to set times", filePath)); - } else { - next(); - } + filetools.fs.utimes(filePath, entry.header.time, entry.header.time, function () { + // best-effort: a utimes failure must not abort extraction + // of already-written content (issue #379) + next(); }); }); } }); } }; - }, callback)(); + }, done)(); }, /** @@ -64838,6 +64909,7 @@ module.exports = function () { switch (val) { case Constants.STORED: this.version = 10; + break; case Constants.DEFLATED: default: this.version = 20; @@ -64849,6 +64921,7 @@ module.exports = function () { return Utils.fromDOS2Date(this.timeval); }, set time(val) { + val = new Date(val); this.timeval = Utils.fromDate2DOS(val); }, @@ -64934,7 +65007,12 @@ module.exports = function () { // get Unix file permissions get fileAttr() { - return (_attr || 0) >> 16 & 0xfff; + // Mask to the 9 rwxrwxrwx bits only. The setuid (0o4000), setgid + // (0o2000) and sticky (0o1000) bits are attacker-controlled archive + // metadata; preserving them on extraction (keepOriginalPermission) + // lets a crafted zip plant a setuid-root binary when extracting as + // root, a local privilege escalation (GHSA-679w-jf3m-wh39). + return ((_attr || 0) >> 16) & 0o777; }, get offset() { @@ -64961,6 +65039,13 @@ module.exports = function () { }, loadLocalHeaderFromBinary: function (/*Buffer*/ input) { + // The LOC offset comes from the central directory and is attacker + // controlled. Reject one that would read past the end of the buffer, + // otherwise readUInt32LE below throws a raw RangeError instead of a + // clean INVALID_LOC (and escapes the async error path). + if (_offset < 0 || _offset + Constants.LOCHDR > input.length) { + throw Utils.Errors.INVALID_LOC(); + } var data = input.slice(_offset, _offset + Constants.LOCHDR); // 30 bytes and should start with "PK\003\004" if (data.readUInt32LE(0) !== Constants.LOCSIG) { @@ -64971,6 +65056,8 @@ module.exports = function () { _localHeader.version = data.readUInt16LE(Constants.LOCVER); // general purpose bit flag _localHeader.flags = data.readUInt16LE(Constants.LOCFLG); + // desc flag + _localHeader.flags_desc = (_localHeader.flags & Constants.FLG_DESC) > 0; // compression method _localHeader.method = data.readUInt16LE(Constants.LOCHOW); // modification time (2 bytes time, 2 bytes date) @@ -65037,7 +65124,11 @@ module.exports = function () { // version needed to extract data.writeUInt16LE(_version, Constants.LOCVER); // general purpose bit flag - data.writeUInt16LE(_flags, Constants.LOCFLG); + // clear bit 3 (data descriptor): we always write the real crc-32 + // and sizes into this local header, so no trailing descriptor is + // emitted. Leaving the flag set would make the output unreadable + // (see issue #555). + data.writeUInt16LE(_flags & ~Constants.FLG_DESC, Constants.LOCFLG); // compression method data.writeUInt16LE(_method, Constants.LOCHOW); // modification time (2 bytes time, 2 bytes date) @@ -65065,7 +65156,9 @@ module.exports = function () { // version needed to extract data.writeUInt16LE(_version, Constants.CENVER); // encrypt, decrypt flags - data.writeUInt16LE(_flags, Constants.CENFLG); + // clear bit 3 (data descriptor) to match the local header we emit + // (real crc/sizes are written, no descriptor follows the data) — issue #555 + data.writeUInt16LE(_flags & ~Constants.FLG_DESC, Constants.CENFLG); // compression method data.writeUInt16LE(_method, Constants.CENHOW); // modification time (2 bytes time, 2 bytes date) @@ -65150,6 +65243,8 @@ module.exports = function () { _offset = 0, _commentLength = 0; + const needsZip64 = () => _volumeEntries > Constants.EF_ZIP64_OR_16 || _totalEntries > Constants.EF_ZIP64_OR_16 || _size > Constants.EF_ZIP64_OR_32 || _offset > Constants.EF_ZIP64_OR_32; + return { get diskEntries() { return _volumeEntries; @@ -65187,7 +65282,7 @@ module.exports = function () { }, get mainHeaderSize() { - return Constants.ENDHDR + _commentLength; + return (needsZip64() ? Constants.ZIP64HDR + Constants.END64HDR : 0) + Constants.ENDHDR + _commentLength; }, loadFromBinary: function (/*Buffer*/ data) { @@ -65217,7 +65312,7 @@ module.exports = function () { // total number of entries _totalEntries = Utils.readBigUInt64LE(data, Constants.ZIP64TOT); // central directory size in bytes - _size = Utils.readBigUInt64LE(data, Constants.ZIP64SIZE); + _size = Utils.readBigUInt64LE(data, Constants.ZIP64SIZB); // offset of first CEN header _offset = Utils.readBigUInt64LE(data, Constants.ZIP64OFF); @@ -65226,22 +65321,67 @@ module.exports = function () { }, toBinary: function () { - var b = Buffer.alloc(Constants.ENDHDR + _commentLength); + if (!needsZip64()) { + var b = Buffer.alloc(Constants.ENDHDR + _commentLength); + // "PK 05 06" signature + b.writeUInt32LE(Constants.ENDSIG, 0); + b.writeUInt32LE(0, 4); + // number of entries on this volume + b.writeUInt16LE(_volumeEntries, Constants.ENDSUB); + // total number of entries + b.writeUInt16LE(_totalEntries, Constants.ENDTOT); + // central directory size in bytes + b.writeUInt32LE(_size, Constants.ENDSIZ); + // offset of first CEN header + b.writeUInt32LE(_offset, Constants.ENDOFF); + // zip file comment length + b.writeUInt16LE(_commentLength, Constants.ENDCOM); + // fill comment memory with spaces so no garbage is left there + b.fill(" ", Constants.ENDHDR); + + return b; + } + + var b = Buffer.alloc(this.mainHeaderSize); + let offset = 0; + + // Zip64 end of central directory record. + b.writeUInt32LE(Constants.ZIP64SIG, offset); + Utils.writeBigUInt64LE(b, Constants.ZIP64HDR - Constants.ZIP64LEAD, offset + Constants.ZIP64SIZE); + b.writeUInt16LE(45, offset + Constants.ZIP64VEM); + b.writeUInt16LE(45, offset + Constants.ZIP64VER); + b.writeUInt32LE(0, offset + Constants.ZIP64DSK); + b.writeUInt32LE(0, offset + Constants.ZIP64DSKDIR); + Utils.writeBigUInt64LE(b, _volumeEntries, offset + Constants.ZIP64SUB); + Utils.writeBigUInt64LE(b, _totalEntries, offset + Constants.ZIP64TOT); + Utils.writeBigUInt64LE(b, _size, offset + Constants.ZIP64SIZB); + Utils.writeBigUInt64LE(b, _offset, offset + Constants.ZIP64OFF); + + const zip64EndOffset = _offset + _size; + offset += Constants.ZIP64HDR; + + // Zip64 end of central directory locator. + b.writeUInt32LE(Constants.END64SIG, offset); + b.writeUInt32LE(0, offset + Constants.END64START); + Utils.writeBigUInt64LE(b, zip64EndOffset, offset + Constants.END64OFF); + b.writeUInt32LE(1, offset + Constants.END64NUMDISKS); + offset += Constants.END64HDR; + // "PK 05 06" signature - b.writeUInt32LE(Constants.ENDSIG, 0); - b.writeUInt32LE(0, 4); + b.writeUInt32LE(Constants.ENDSIG, offset); + b.writeUInt32LE(0, offset + 4); // number of entries on this volume - b.writeUInt16LE(_volumeEntries, Constants.ENDSUB); + b.writeUInt16LE(Math.min(_volumeEntries, Constants.EF_ZIP64_OR_16), offset + Constants.ENDSUB); // total number of entries - b.writeUInt16LE(_totalEntries, Constants.ENDTOT); + b.writeUInt16LE(Math.min(_totalEntries, Constants.EF_ZIP64_OR_16), offset + Constants.ENDTOT); // central directory size in bytes - b.writeUInt32LE(_size, Constants.ENDSIZ); + b.writeUInt32LE(Math.min(_size, Constants.EF_ZIP64_OR_32), offset + Constants.ENDSIZ); // offset of first CEN header - b.writeUInt32LE(_offset, Constants.ENDOFF); + b.writeUInt32LE(Math.min(_offset, Constants.EF_ZIP64_OR_32), offset + Constants.ENDOFF); // zip file comment length - b.writeUInt16LE(_commentLength, Constants.ENDCOM); + b.writeUInt16LE(_commentLength, offset + Constants.ENDCOM); // fill comment memory with spaces so no garbage is left there - b.fill(" ", Constants.ENDHDR); + b.fill(" ", offset + Constants.ENDHDR); return b; }, @@ -65326,11 +65466,18 @@ exports.ZipCrypto = __nccwpck_require__(62689); /***/ 79540: /***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { -const version = +(process.versions ? process.versions.node : "").split(".")[0] || 0; +const version = +(process?.versions?.node ?? "").split(".")[0] || 0; +const Errors = __nccwpck_require__(36232); module.exports = function (/*Buffer*/ inbuf, /*number*/ expectedLength) { var zlib = __nccwpck_require__(43106); - const option = version >= 15 && expectedLength > 0 ? { maxOutputLength: expectedLength } : {}; + // Cap decompression output at the entry's declared uncompressed size to bound + // decompression bombs (CVE-2026-39244). A declared size of 0 must not disable + // the cap: a genuinely empty entry inflates to 0 bytes, so a 1-byte floor + // still lets it through while stopping a bomb that lies about its size + // (GHSA-rcw4-f5rp-g42v). zlib requires maxOutputLength >= 1. + const maxOutputLength = expectedLength > 0 ? expectedLength : 1; + const option = version >= 15 ? { maxOutputLength } : {}; return { inflate: function () { @@ -65340,12 +65487,35 @@ module.exports = function (/*Buffer*/ inbuf, /*number*/ expectedLength) { inflateAsync: function (/*Function*/ callback) { var tmp = zlib.createInflateRaw(option), parts = [], - total = 0; + total = 0, + done = false; + const fail = function (err) { + if (done) return; + done = true; + tmp.destroy(); + callback && callback(Buffer.alloc(0), err); + }; + // Route stream errors (e.g. Z_DATA_ERROR on malformed input) through the + // callback. Without an "error" listener zlib re-throws the event as an + // uncaught exception on a later tick, crashing the host process instead + // of failing the call (GHSA-8238-w5pm-2374). + tmp.on("error", function (err) { + fail(err); + }); tmp.on("data", function (data) { - parts.push(data); + if (done) return; total += data.length; + // The streaming API ignores maxOutputLength, so enforce the cap by + // hand; otherwise the async path decompresses without limit while the + // sync path is capped (GHSA-v429-h5qx-84wm, GHSA-c6fg-446q-cg94). + if (total > maxOutputLength) { + return fail(Errors.MAX_OUTPUT_EXCEEDED()); + } + parts.push(data); }); tmp.on("end", function () { + if (done) return; + done = true; var buf = Buffer.alloc(total), written = 0; buf.fill(0); @@ -65725,6 +65895,7 @@ const errors = { /* ZipEntry error messages*/ NO_DATA: "Nothing to decompress", BAD_CRC: "CRC32 checksum failed {0}", + MAX_OUTPUT_EXCEEDED: "Decompressed data exceeds the declared uncompressed size", FILE_IN_THE_WAY: "There is a file in the way: {0}", UNKNOWN_METHOD: "Invalid/unsupported compression method", @@ -65746,11 +65917,13 @@ const errors = { DISK_ENTRY_TOO_LARGE: "Number of disk entries is too large", NO_ZIP: "No zip file was loaded", NO_ENTRY: "Entry doesn't exist", + DUPLICATE_ENTRY: "Duplicate entry name {0}", DIRECTORY_CONTENT_ERROR: "A directory cannot have content", FILE_NOT_FOUND: 'File not found: "{0}"', NOT_IMPLEMENTED: "Not implemented", INVALID_FILENAME: "Invalid filename", INVALID_FORMAT: "Invalid or unsupported zip format. No END header found", + ZIP64_VALUE_TOO_LARGE: "Zip64 value exceeds the maximum safe integer", INVALID_PASS_PARAM: "Incompatible password parameter", WRONG_PASSWORD: "Wrong Password", @@ -65927,7 +66100,11 @@ Utils.prototype.makeDir = function (/*String*/ folder) { try { stat = self.fs.statSync(resolvedPath); } catch (e) { - self.fs.mkdirSync(resolvedPath); + if (e.message && e.message.startsWith('ENOENT')) { + self.fs.mkdirSync(resolvedPath); + } else { + throw e; + } } if (stat && stat.isFile()) throw Errors.FILE_IN_THE_WAY(`"${resolvedPath}"`); }); @@ -65981,39 +66158,51 @@ Utils.prototype.writeFileToAsync = function (/*String*/ path, /*Buffer*/ content if (exist && !overwrite) return callback(false); self.fs.stat(path, function (err, stat) { - if (exist && stat.isDirectory()) { + if (exist && stat && stat.isDirectory()) { return callback(false); } var folder = pth.dirname(path); self.fs.exists(folder, function (exists) { - if (!exists) self.makeDir(folder); + if (!exists) { + // makeDir is synchronous and can throw (e.g. EACCES); report failure + // rather than letting it escape this callback as an uncaught exception + try { + self.makeDir(folder); + } catch (e) { + return callback(false); + } + } + + // write the content to an open descriptor, then apply the attributes + const writeToFd = function (fd) { + self.fs.write(fd, content, 0, content.length, 0, function (writeErr) { + self.fs.close(fd, function () { + // surface write failures instead of silently reporting success (issue #402) + if (writeErr) return callback(false); + self.fs.chmod(path, attr || 0o666, function () { + callback(true); + }); + }); + }); + }; self.fs.open(path, "w", 0o666, function (err, fd) { if (err) { + // the target may exist but be read-only: make it writable and retry once self.fs.chmod(path, 0o666, function () { - self.fs.open(path, "w", 0o666, function (err, fd) { - self.fs.write(fd, content, 0, content.length, 0, function () { - self.fs.close(fd, function () { - self.fs.chmod(path, attr || 0o666, function () { - callback(true); - }); - }); - }); + self.fs.open(path, "w", 0o666, function (retryErr, fd) { + // Previously the retry error was ignored and an undefined fd was + // passed to fs.write, throwing an uncaught ERR_INVALID_ARG_TYPE that + // crashed the process (issues #470, #459, #402). Report failure instead. + if (retryErr || !fd) return callback(false); + writeToFd(fd); }); }); } else if (fd) { - self.fs.write(fd, content, 0, content.length, 0, function () { - self.fs.close(fd, function () { - self.fs.chmod(path, attr || 0o666, function () { - callback(true); - }); - }); - }); + writeToFd(fd); } else { - self.fs.chmod(path, attr || 0o666, function () { - callback(true); - }); + callback(false); } }); }); @@ -66021,10 +66210,61 @@ Utils.prototype.writeFileToAsync = function (/*String*/ path, /*Buffer*/ content }); }; +// Guard extraction against writing through a symlink that already exists inside +// the target directory. sanitize() only proves the textual path stays under the +// root; it cannot see that a component on disk is a symlink pointing elsewhere, +// so open()/mkdir() would follow it and write outside the root. Walk every path +// component strictly below root and reject any that is a symlink. Components at +// or above root are the caller's own choice and are left untouched, so a root +// that itself lives under a symlink (e.g. /tmp on macOS) still extracts. +Utils.prototype.assertPathSafe = function (/*String*/ root, /*String*/ target) { + const self = this; + if (typeof self.fs.lstatSync !== "function") return; + + const resolvedRoot = pth.resolve(root); + const resolvedTarget = pth.resolve(target); + if (resolvedTarget === resolvedRoot) return; + + const rel = pth.relative(resolvedRoot, resolvedTarget); + // Not under root: sanitize() is responsible for that case; nothing to walk. + if (!rel || rel === ".." || rel.startsWith(".." + pth.sep) || pth.isAbsolute(rel)) return; + + let cur = resolvedRoot; + for (const part of rel.split(pth.sep)) { + if (!part || part === ".") continue; + cur = pth.join(cur, part); + let stat; + try { + stat = self.fs.lstatSync(cur); + } catch (e) { + break; // component does not exist yet: nothing below it can be a symlink + } + if (stat.isSymbolicLink()) throw Errors.FILE_IN_THE_WAY(`"${cur}"`); + } +}; + Utils.prototype.findFiles = function (/*String*/ path) { const self = this; + const canLstat = typeof self.fs.lstatSync === "function"; + const rootReal = self.fs.realpathSync(path); + + // A symlink whose target lies outside the folder being archived must not be + // followed: statSync would dereference it and copy the target's contents into + // the archive, disclosing files outside the root (GHSA-wx42-xcp7-pgr4). Allow + // symlinks that resolve to a location inside the root, reject any that escape. + function escapesRoot(/*String*/ p) { + if (!canLstat) return false; + if (!self.fs.lstatSync(p).isSymbolicLink()) return false; + let real; + try { + real = self.fs.realpathSync(p); + } catch (e) { + return true; // dangling or unresolvable symlink: do not follow + } + return !(real === rootReal || real.startsWith(rootReal + pth.sep)); + } - function findSync(/*String*/ dir, /*RegExp*/ pattern, /*Boolean*/ recursive) { + function findSync(/*String*/ dir, /*RegExp*/ pattern, /*Boolean*/ recursive, /*Set*/ visited) { if (typeof pattern === "boolean") { recursive = pattern; pattern = undefined; @@ -66032,18 +66272,31 @@ Utils.prototype.findFiles = function (/*String*/ path) { let files = []; self.fs.readdirSync(dir).forEach(function (file) { const path = pth.join(dir, file); + + if (escapesRoot(path)) return; + const stat = self.fs.statSync(path); if (!pattern || pattern.test(path)) { files.push(pth.normalize(path) + (stat.isDirectory() ? self.sep : "")); } - if (stat.isDirectory() && recursive) files = files.concat(findSync(path, pattern, recursive)); + if (stat.isDirectory() && recursive) { + // Descend by resolved real path and skip directories we have already + // visited. This stops a symlink that points back to an ancestor from + // recursing forever until the path fails with ELOOP / ENAMETOOLONG + // (issue #541). + const realDir = self.fs.realpathSync(path); + if (!visited.has(realDir)) { + visited.add(realDir); + files = files.concat(findSync(path, pattern, recursive, visited)); + } + } }); return files; } - return findSync(path, undefined, true); + return findSync(path, undefined, true, new Set([rootReal])); }; /** @@ -66061,29 +66314,81 @@ Utils.prototype.findFiles = function (/*String*/ path) { */ Utils.prototype.findFilesAsync = function (dir, cb) { const self = this; - let results = []; - self.fs.readdir(dir, function (err, list) { - if (err) return cb(err); - let list_length = list.length; - if (!list_length) return cb(null, results); - list.forEach(function (file) { - file = pth.join(dir, file); - self.fs.stat(file, function (err, stat) { - if (err) return cb(err); - if (stat) { - results.push(pth.normalize(file) + (stat.isDirectory() ? self.sep : "")); - if (stat.isDirectory()) { - self.findFilesAsync(file, function (err, res) { - if (err) return cb(err); - results = results.concat(res); - if (!--list_length) cb(null, results); - }); - } else { - if (!--list_length) cb(null, results); + const results = []; + let finished = false; + const finish = function (err) { + if (finished) return; + finished = true; + cb(err, err ? undefined : results); + }; + + const canLstat = typeof self.fs.lstat === "function"; + let rootReal = null; + + // Reject a symlink whose target escapes the root being archived, so its + // contents are not dereferenced and copied into the archive + // (GHSA-wx42-xcp7-pgr4). A symlink resolving to a location inside the root is + // allowed; a dangling or escaping one is skipped. Calls back (err, escapes). + const escapesRoot = function (file, cb) { + if (!canLstat) return cb(null, false); + self.fs.lstat(file, function (err, lst) { + if (err) return cb(err); + if (!lst || !lst.isSymbolicLink()) return cb(null, false); + self.fs.realpath(file, function (err, real) { + if (err) return cb(null, true); // dangling: do not follow + cb(null, !(real === rootReal || real.startsWith(rootReal + pth.sep))); + }); + }); + }; + + // Descend by resolved real path and skip directories already visited, so a + // symlink pointing back to an ancestor cannot recurse forever (issue #541). + const walk = function (dir, visited, done) { + self.fs.readdir(dir, function (err, list) { + if (err) return done(err); + let pending = list.length; + if (!pending) return done(); + list.forEach(function (name) { + const file = pth.join(dir, name); + escapesRoot(file, function (err, escapes) { + if (err) return done(err); + if (escapes) { + if (!--pending) done(); + return; } - } + self.fs.stat(file, function (err, stat) { + if (err) return done(err); + if (!stat) { + if (!--pending) done(); + return; + } + results.push(pth.normalize(file) + (stat.isDirectory() ? self.sep : "")); + if (!stat.isDirectory()) { + if (!--pending) done(); + return; + } + self.fs.realpath(file, function (err, realDir) { + if (err) return done(err); + if (visited.has(realDir)) { + if (!--pending) done(); + return; + } + visited.add(realDir); + walk(file, visited, function (err) { + if (err) return done(err); + if (!--pending) done(); + }); + }); + }); + }); }); }); + }; + + self.fs.realpath(dir, function (err, realDir) { + if (err) return finish(err); + rootReal = realDir; + walk(dir, new Set([realDir]), finish); }); }; @@ -66164,13 +66469,13 @@ Utils.findLast = function (arr, callback) { return void 0; }; -// make abolute paths taking prefix as root folder +// make absolute paths taking prefix as root folder Utils.sanitize = function (/*string*/ prefix, /*string*/ name) { prefix = pth.resolve(pth.normalize(prefix)); var parts = name.split("/"); for (var i = 0, l = parts.length; i < l; i++) { var path = pth.normalize(pth.join(prefix, parts.slice(i, l).join(pth.sep))); - if (path.indexOf(prefix) === 0) { + if (path === prefix || path.startsWith(prefix + pth.sep)) { return path; } } @@ -66190,10 +66495,23 @@ Utils.toBuffer = function toBuffer(/*buffer, Uint8Array, string*/ input, /* func }; Utils.readBigUInt64LE = function (/*Buffer*/ buffer, /*int*/ index) { - var slice = Buffer.from(buffer.slice(index, index + 8)); - slice.swap64(); + const lo = buffer.readUInt32LE(index); + const hi = buffer.readUInt32LE(index + 4); + const value = hi * 0x100000000 + lo; + // The result is a JS number, so values above 2^53 - 1 cannot be represented + // exactly. These are zip64 sizes/offsets/counts used as buffer indices; a + // silently rounded value would misparse the archive. Reject instead. + if (value > Number.MAX_SAFE_INTEGER) { + throw Errors.ZIP64_VALUE_TOO_LARGE(); + } + return value; +}; - return parseInt(`0x${slice.toString("hex")}`); +Utils.writeBigUInt64LE = function (/*Buffer*/ buffer, /*Number*/ value, /*int*/ index) { + const lo = value >>> 0; + const hi = Math.floor(value / 0x100000000) >>> 0; + buffer.writeUInt32LE(lo, index); + buffer.writeUInt32LE(hi, index + 4); }; Utils.fromDOS2Date = function (val) { @@ -66246,52 +66564,36 @@ module.exports = function (/** object */ options, /*Buffer*/ input) { return Buffer.alloc(0); } _extralocal = _centralHeader.loadLocalHeaderFromBinary(input); - return input.slice(_centralHeader.realDataOffset, _centralHeader.realDataOffset + _centralHeader.compressedSize); + const dataOffset = _centralHeader.realDataOffset; + const dataEnd = dataOffset + _centralHeader.compressedSize; + // The offsets and sizes come from attacker-controlled headers. Require the + // declared compressed extent to be fully present rather than letting slice() + // silently clamp to a short buffer (which only surfaces later as a CRC + // failure). Fail loudly with a header error instead (GHSA-wwrv-q5gf-5843). + if (dataOffset < 0 || dataEnd < dataOffset || dataEnd > input.length) { + throw Utils.Errors.INVALID_LOC(); + } + return input.slice(dataOffset, dataEnd); } function crc32OK(data) { - // if bit 3 (0x08) of the general-purpose flags field is set, then the CRC-32 and file sizes are not known when the local header is written - if (!_centralHeader.flags_desc) { - if (Utils.crc32(data) !== _centralHeader.localHeader.crc) { - return false; - } - } else { - const descriptor = {}; - const dataEndOffset = _centralHeader.realDataOffset + _centralHeader.compressedSize; - // no descriptor after compressed data, instead new local header - if (input.readUInt32LE(dataEndOffset) == Constants.LOCSIG || input.readUInt32LE(dataEndOffset) == Constants.CENSIG) { - throw Utils.Errors.DESCRIPTOR_NOT_EXIST(); - } - - // get decriptor data - if (input.readUInt32LE(dataEndOffset) == Constants.EXTSIG) { - // descriptor with signature - descriptor.crc = input.readUInt32LE(dataEndOffset + Constants.EXTCRC); - descriptor.compressedSize = input.readUInt32LE(dataEndOffset + Constants.EXTSIZ); - descriptor.size = input.readUInt32LE(dataEndOffset + Constants.EXTLEN); - } else if (input.readUInt16LE(dataEndOffset + 12) === 0x4b50) { - // descriptor without signature (we check is new header starting where we expect) - descriptor.crc = input.readUInt32LE(dataEndOffset + Constants.EXTCRC - 4); - descriptor.compressedSize = input.readUInt32LE(dataEndOffset + Constants.EXTSIZ - 4); - descriptor.size = input.readUInt32LE(dataEndOffset + Constants.EXTLEN - 4); - } else { - throw Utils.Errors.DESCRIPTOR_UNKNOWN(); - } - - // check data integrity - if (descriptor.compressedSize !== _centralHeader.compressedSize || descriptor.size !== _centralHeader.size || descriptor.crc !== _centralHeader.crc) { - throw Utils.Errors.DESCRIPTOR_FAULTY(); - } - if (Utils.crc32(data) !== descriptor.crc) { - return false; - } + // When bit 3 (0x08) of the general-purpose flags is set, the crc-32 and + // sizes were unknown when the local file header was written, so that + // header carries placeholder zeros and the real values are repeated in a + // data descriptor after the compressed data. adm-zip always parses the + // central directory, whose header holds the authoritative crc-32 and + // sizes, so we validate the payload against that value. + // + // Earlier versions instead located and parsed the trailing descriptor and + // threw when it was absent or in an unexpected shape. Many valid archives + // set the descriptor flag but write the real crc/sizes into the local and + // central headers without emitting a descriptor (or write one we did not + // recognise), so that strict handling rejected readable zips + // (issues #533, #548, #554). Trusting the central-directory crc keeps the + // integrity check while accepting those archives. + const expectedCrc = _centralHeader.flags_desc || _centralHeader.localHeader.flags_desc ? _centralHeader.crc : _centralHeader.localHeader.crc; - // @TODO: zip64 bit descriptor fields - // if bit 3 is set and any value in local header "zip64 Extended information" extra field are set 0 (place holder) - // then 64-bit descriptor format is used instead of 32-bit - // central header - "zip64 Extended information" extra field should store real values and not place holders - } - return true; + return Utils.crc32(data) === expectedCrc; } function decompress(/*Boolean*/ async, /*Function*/ callback, /*String, Buffer*/ pass) { @@ -66306,25 +66608,44 @@ module.exports = function (/** object */ options, /*Buffer*/ input) { return Buffer.alloc(0); } - var compressedData = getCompressedDataFromZip(); + var compressedData; + try { + compressedData = getCompressedDataFromZip(); - if (compressedData.length === 0) { - // File is empty, nothing to decompress. - if (async && callback) callback(compressedData); - return compressedData; - } + if (compressedData.length === 0) { + // File is empty, nothing to decompress. + if (async && callback) callback(compressedData); + return compressedData; + } - if (_centralHeader.encrypted) { - if ("string" !== typeof pass && !Buffer.isBuffer(pass)) { - throw Utils.Errors.INVALID_PASS_PARAM(); + if (_centralHeader.encrypted) { + if ("string" !== typeof pass && !Buffer.isBuffer(pass)) { + throw Utils.Errors.INVALID_PASS_PARAM(); + } + compressedData = Methods.ZipCrypto.decrypt(compressedData, _centralHeader, pass); } - compressedData = Methods.ZipCrypto.decrypt(compressedData, _centralHeader, pass); + } catch (err) { + // These synchronous parse/setup steps run before any callback fires. + // In async mode a malformed local header (e.g. a bad LOC offset) would + // otherwise throw out of getDataAsync and bypass the callback error + // channel, crashing the caller. Route it through the callback instead. + if (async && callback) { + callback(Buffer.alloc(0), err); + return Buffer.alloc(0); + } + throw err; } - var data = Buffer.alloc(_centralHeader.size); + var data; switch (_centralHeader.method) { case Utils.Constants.STORED: + // STORED entries are not compressed, so the uncompressed output is + // exactly the bytes present in the archive. Allocate from the real + // data length rather than the attacker-declared central-directory + // size, otherwise a tiny archive can declare a huge size and force a + // multi-gigabyte allocation before any validation (CVE-2026-39244). + data = Buffer.alloc(compressedData.length); compressedData.copy(data); if (!crc32OK(data)) { if (async && callback) callback(data, Utils.Errors.BAD_CRC()); //si added error @@ -66335,23 +66656,28 @@ module.exports = function (/** object */ options, /*Buffer*/ input) { return data; } case Utils.Constants.DEFLATED: + // Do not pre-allocate the declared uncompressed size. The inflater + // grows its output buffer as zlib emits data and caps the total at + // the declared size (maxOutputLength), so a bogus size can no longer + // trigger an eager allocation before the data is read (CVE-2026-39244). var inflater = new Methods.Inflater(compressedData, _centralHeader.size); if (!async) { - const result = inflater.inflate(data); - result.copy(data, 0); + data = inflater.inflate(); if (!crc32OK(data)) { throw Utils.Errors.BAD_CRC(`"${decoder.decode(_entryName)}"`); } return data; } else { - inflater.inflateAsync(function (result) { - result.copy(result, 0); - if (callback) { - if (!crc32OK(result)) { - callback(result, Utils.Errors.BAD_CRC()); //si added error - } else { - callback(result); - } + inflater.inflateAsync(function (result, err) { + if (!callback) return; + if (err) { + // surface inflater/stream failures instead of validating + // the empty placeholder buffer against the CRC + callback(Buffer.alloc(0), err); + } else if (!crc32OK(result)) { + callback(result, Utils.Errors.BAD_CRC()); //si added error + } else { + callback(result); } }); } @@ -66407,7 +66733,7 @@ module.exports = function (/** object */ options, /*Buffer*/ input) { } function readUInt64LE(buffer, offset) { - return (buffer.readUInt32LE(offset + 4) << 4) + buffer.readUInt32LE(offset); + return Utils.readBigUInt64LE(buffer, offset); } function parseExtra(data) { @@ -66501,10 +66827,12 @@ module.exports = function (/** object */ options, /*Buffer*/ input) { }, get name() { - var n = decoder.decode(_entryName); + const n = decoder.decode(_entryName); + // For directories the name is the last path segment; drop the trailing + // separator first so "a/b/c/" yields "c" and not "" (issue #466). return _isDirectory ? n - .substr(n.length - 1) + .replace(/[/\\]$/, "") .split("/") .pop() : n.split("/").pop(); @@ -66637,7 +66965,10 @@ const Utils = __nccwpck_require__(55391); module.exports = function (/*Buffer|null*/ inBuffer, /** object */ options) { var entryList = [], - entryTable = {}, + // prototype-less: entry names come from untrusted input, so keys like + // "__proto__" or "constructor" must be plain data, not touch the prototype + // chain (which otherwise crashes addFile and hides such entries) + entryTable = Object.create(null), _comment = Buffer.alloc(0), mainHeader = new Headers.MainHeader(), loadedEntries = false; @@ -66687,7 +67018,7 @@ module.exports = function (/*Buffer|null*/ inBuffer, /** object */ options) { function readEntries() { loadedEntries = true; - entryTable = {}; + entryTable = Object.create(null); if (mainHeader.diskEntries > (inBuffer.length - mainHeader.offset) / Utils.Constants.CENHDR) { throw Utils.Errors.DISK_ENTRY_TOO_LARGE(); } @@ -66708,6 +67039,16 @@ module.exports = function (/*Buffer|null*/ inBuffer, /** object */ options) { index += entry.header.centralHeaderSize; + // Reject archives that declare the same entry name twice. adm-zip keeps + // every entry in entryList but only the last in entryTable, so getEntry() + // (table) and extractAllTo() (list) could resolve one name to different + // content: an app that validates entry bytes via getEntry() before + // extracting could approve one file while a different one lands on disk + // (GHSA-p634-w6r4-rjp2). Fail closed on the ambiguity. + if (entry.entryName in entryTable) { + throw Utils.Errors.DUPLICATE_ENTRY(`"${entry.entryName}"`); + } + entryList[i] = entry; entryTable[entry.entryName] = entry; } @@ -66764,7 +67105,14 @@ module.exports = function (/*Buffer|null*/ inBuffer, /** object */ options) { function sortEntries() { if (entryList.length > 1 && !noSort) { - entryList.sort((a, b) => a.entryName.toLowerCase().localeCompare(b.entryName.toLowerCase())); + // Decode + lowercase each name once rather than on every comparison: + // the entryName getter re-decodes the underlying buffer on each access, + // so the previous inline comparator did O(n log n) redundant decoding. + // Ordering is unchanged (same localeCompare on the same keys). + entryList = entryList + .map((entry) => ({ entry, key: entry.entryName.toLowerCase() })) + .sort((a, b) => a.key.localeCompare(b.key)) + .map((pair) => pair.entry); } } @@ -66976,7 +67324,7 @@ module.exports = function (/*Buffer|null*/ inBuffer, /** object */ options) { // write main header const mh = mainHeader.toBinary(); if (_comment) { - _comment.copy(mh, Utils.Constants.ENDHDR); // add zip file comment + _comment.copy(mh, mh.length - _comment.length); // add zip file comment } mh.copy(outBuffer, dindex); @@ -67054,7 +67402,7 @@ module.exports = function (/*Buffer|null*/ inBuffer, /** object */ options) { const mh = mainHeader.toBinary(); if (_comment) { - _comment.copy(mh, Utils.Constants.ENDHDR); // add zip file comment + _comment.copy(mh, mh.length - _comment.length); // add zip file comment } mh.copy(outBuffer, dindex); // write main header @@ -96075,6 +96423,20 @@ var escClose = '\0CLOSE'+Math.random()+'\0'; var escComma = '\0COMMA'+Math.random()+'\0'; var escPeriod = '\0PERIOD'+Math.random()+'\0'; +var EXPANSION_MAX = 100000 + +// `EXPANSION_MAX` caps the *number* of expansions, but not their length. An +// input like `'{a,b}'.repeat(1500)` stays under that count - its output is +// truncated to 100k results - while making every result ~1500 characters +// long. The result set, and the intermediate arrays built while combining +// brace sets, then grow large enough to exhaust memory and crash the process +// (CVE-2026-14257). `EXPANSION_MAX_LENGTH` bounds the total number of +// characters the accumulator may hold at any point, so memory stays flat no +// matter how many brace groups are chained. The limit sits well above any +// realistic expansion (100k results hitting `EXPANSION_MAX` measure ~1M +// characters) so legitimate input is unaffected. +var EXPANSION_MAX_LENGTH = 4000000 + function numeric(str) { return parseInt(str, 10) == str ? parseInt(str, 10) @@ -96133,7 +96495,8 @@ function expandTop(str, options) { return []; options = options || {}; - var max = options.max == null ? Infinity : options.max; + var max = options.max == null ? EXPANSION_MAX : options.max; + var maxLength = options.maxLength == null ? EXPANSION_MAX_LENGTH : options.maxLength; // I don't know why Bash 4.3 does this, but it does. // Anything starting with {} will have the first two bytes preserved @@ -96145,7 +96508,7 @@ function expandTop(str, options) { str = '\\{\\}' + str.substr(2); } - return expand(escapeBraces(str), max, true).map(unescapeBraces); + return expand(escapeBraces(str), max, maxLength, true).map(unescapeBraces); } function embrace(str) { @@ -96162,27 +96525,142 @@ function gte(i, y) { return i >= y; } -function expand(str, max, isTop) { - var expansions = []; +// Build `{ acc[a] + pre + values[v] }` for every combination, capping the +// number of results at `max` and the total number of characters at `maxLength`. +// This is the one place output grows, so bounding it here keeps the single +// accumulator - and therefore memory - flat regardless of how many brace groups +// are combined (CVE-2026-14257). +function combine( + acc, + pre, + values, + max, + maxLength, + dropEmpties +) { + var out = [] + var length = 0 + for (var a = 0; a < acc.length; a++) { + for (var v = 0; v < values.length; v++) { + if (out.length >= max) return out + var expansion = acc[a] + pre + values[v] + // Bash drops empty results at the top level. Skip them before they count + // against `max`, so `max` bounds the number of *kept* results. + if (dropEmpties && !expansion) continue + if (length + expansion.length > maxLength) return out + out.push(expansion) + length += expansion.length + } + } + return out +} + +// The expansion values of a single numeric (`1..5`) or alphabetic (`a..e..2`) +// sequence body. +function expandSequence( + body, + isAlphaSequence, + max, + maxLength +) { + var n = body.split(/\.\./) + var N = [] + // A sequence body always splits into two or three parts, but the compiler + // can't know that. + /* c8 ignore start */ + if (n[0] === undefined || n[1] === undefined) { + return N + } + /* c8 ignore stop */ + var x = numeric(n[0]) + var y = numeric(n[1]) + var width = Math.max(n[0].length, n[1].length) + var incr = + n.length === 3 && n[2] !== undefined ? + Math.max(Math.abs(numeric(n[2])), 1) + : 1 + var test = lte + var reverse = y < x + if (reverse) { + incr *= -1 + test = gte + } + var pad = n.some(isPadded) + + var length = 0 + for (var i = x; test(i, y) && N.length < max; i += incr) { + var c + if (isAlphaSequence) { + c = String.fromCharCode(i) + if (c === '\\') { + c = '' + } + } else { + c = String(i) + if (pad) { + var need = width - c.length + if (need > 0) { + var z = new Array(need + 1).join('0') + if (i < 0) { + c = '-' + z + c.slice(1) + } else { + c = z + c + } + } + } + } + if (length + c.length > maxLength) break + N.push(c) + length += c.length + } + return N +} + +function expand( + str, + max, + maxLength, + isTop +) { + // Consume the string's top-level brace groups left to right, threading a + // running set of combined prefixes (`acc`). Expanding the tail iteratively - + // rather than recursing on `m.post` once per group - keeps the native stack + // depth constant, so deeply chained input (`'{a,b}'.repeat(3000)`) can no + // longer overflow the stack, and leaves a single accumulator whose size + // `maxLength` bounds directly (CVE-2026-14257). + var acc = [''] + + // Bash drops empty results, but only when the *first* top-level group is a + // comma set - a sequence like `{a..\}` may legitimately yield ''. The drop + // is on the final strings, so it is applied to whichever `combine` produces + // them (the one with no brace set left in the tail). + var dropEmpties = false + var firstGroup = true - // The `{a},b}` rewrite below restarts expansion on a rewritten string with - // the same `max` and `isTop = true`. Loop instead of recursing so a long run - // of non-expanding `{}` groups can't exhaust the call stack. for (;;) { const m = balanced('{', '}', str) - if (!m) return [str] + + // No brace set left: the rest of the string is literal. + if (!m) { + return combine(acc, str, [''], max, maxLength, dropEmpties) + } // no need to expand pre, since it is guaranteed to be free of brace-sets const pre = m.pre - if (/\$$/.test(m.pre)) { - const post = - m.post.length ? expand(m.post, max, false) : [''] - for (let k = 0; k < post.length && k < max; k++) { - const expansion = pre + '{' + m.body + '}' + post[k] - expansions.push(expansion) - } - return expansions + if (/\$$/.test(pre)) { + acc = combine( + acc, + pre + '{' + m.body + '}', + [''], + max, + maxLength, + dropEmpties && !m.post.length + ) + firstGroup = false + if (!m.post.length) break + str = m.post + continue } var isNumericSequence = /^-?\d+\.\.-?\d+(?:\.\.-?\d+)?$/.test(m.body); @@ -96196,91 +96674,82 @@ function expand(str, max, isTop) { isTop = true; continue; } - return [str]; + // Nothing here expands, so the whole remaining string is literal. + return combine( + acc, + pre + '{' + m.body + '}' + m.post, + [''], + max, + maxLength, + dropEmpties + ) } - // Only expand post once we know this brace set actually expands. Computing - // it before the early returns above expanded post a second time on every - // non-expanding `{}`, which is what made inputs like `a{},{},{}...` blow up - // exponentially. - const post = - m.post.length ? expand(m.post, max, false) : [''] - var n; + if (firstGroup) { + dropEmpties = isTop && !isSequence + firstGroup = false + } + + var values; if (isSequence) { - n = m.body.split(/\.\./); + values = expandSequence(m.body, isAlphaSequence, max, maxLength); } else { - n = parseCommaParts(m.body); - if (n.length === 1) { + var n = parseCommaParts(m.body); + if (n.length === 1 && n[0] !== undefined) { // x{{a,b}}y ==> x{a}y x{b}y - n = expand(n[0], max, false).map(embrace); + n = expand(n[0], max, maxLength, false).map(embrace); + //XXX is this necessary? Can't seem to hit it in tests. + /* c8 ignore start */ if (n.length === 1) { - return post.map(function(p) { - return m.pre + n[0] + p; - }); + acc = combine( + acc, + pre + n[0], + [''], + max, + maxLength, + dropEmpties && !m.post.length + ) + if (!m.post.length) break + str = m.post + continue } + /* c8 ignore stop */ } - } - - // at this point, n is the parts, and we know it's not a comma set - // with a single entry. - var N; - if (isSequence) { - var x = numeric(n[0]); - var y = numeric(n[1]); - var width = Math.max(n[0].length, n[1].length) - var incr = n.length == 3 - ? Math.max(Math.abs(numeric(n[2])), 1) - : 1; - var test = lte; - var reverse = y < x; - if (reverse) { - incr *= -1; - test = gte; + // Values that `combine` is going to drop as empty produce no result, so + // they must not count against `max` - otherwise `{a,,b}` with `max: 2` + // would stop at `['a', '']` and yield one result instead of two. Skipping + // them outright keeps `values` bounded while leaving `max` a bound on + // *kept* results. + var dropsEmpties = dropEmpties && !m.post.length && !pre + for (var d = 0; dropsEmpties && d < acc.length; d++) { + if (acc[d]) { + dropsEmpties = false + } } - var pad = n.some(isPadded); - - N = []; - for (var i = x; test(i, y) && N.length < max; i += incr) { - var c; - if (isAlphaSequence) { - c = String.fromCharCode(i); - if (c === '\\') - c = ''; - } else { - c = String(i); - if (pad) { - var need = width - c.length; - if (need > 0) { - var z = new Array(need + 1).join('0'); - if (i < 0) - c = '-' + z + c.slice(1); - else - c = z + c; - } + values = [] + var valuesLength = 0 + outer: for (var j = 0; j < n.length; j++) { + var expanded = expand(n[j], max, maxLength, false) + for (var k = 0; k < expanded.length; k++) { + var v = expanded[k] + if (dropsEmpties && !v) continue + if (values.length >= max || valuesLength + v.length > maxLength) { + break outer } + values.push(v) + valuesLength += v.length } - N.push(c); - } - } else { - N = []; - - for (var j = 0; j < n.length; j++) { - N.push.apply(N, expand(n[j], max, false)); - } - } - - for (var j = 0; j < N.length; j++) { - for (var k = 0; k < post.length && expansions.length < max; k++) { - var expansion = pre + N[j] + post[k]; - if (!isTop || isSequence || expansion) - expansions.push(expansion); } } - return expansions; + acc = combine(acc, pre, values, max, maxLength, dropEmpties && !m.post.length) + if (!m.post.length) break + str = m.post } + + return acc } @@ -147667,7 +148136,13 @@ function processHeader (request, key, val) { } else if (typeof val[i] === 'object') { throw new InvalidArgumentError(`invalid ${key} header`) } else { - arr.push(`${val[i]}`) + // Coerce primitives (and reject unsafe coercions such as functions + // with a crafted toString/Symbol.toPrimitive). + const str = `${val[i]}` + if (!isValidHeaderValue(str)) { + throw new InvalidArgumentError(`invalid ${key} header`) + } + arr.push(str) } } val = arr @@ -147678,7 +148153,12 @@ function processHeader (request, key, val) { } else if (val === null) { val = '' } else { + // Coerce primitives (and reject unsafe coercions such as functions + // with a crafted toString/Symbol.toPrimitive). val = `${val}` + if (!isValidHeaderValue(val)) { + throw new InvalidArgumentError(`invalid ${key} header`) + } } if (headerName === 'host') { @@ -149055,6 +149535,7 @@ const { RequestContentLengthMismatchError, ResponseContentLengthMismatchError, RequestAbortedError, + InvalidArgumentError, HeadersTimeoutError, HeadersOverflowError, SocketError, @@ -150038,8 +150519,16 @@ function writeH1 (client, request) { } body = bodyStream.stream contentLength = bodyStream.length - } else if (util.isBlobLike(body) && request.contentType == null && body.type) { - headers.push('content-type', body.type) + } else if (util.isBlobLike(body) && request.contentType == null) { + const contentType = body.type + if (contentType) { + const contentTypeValue = `${contentType}` + if (!util.isValidHeaderValue(contentTypeValue)) { + util.errorRequest(client, request, new InvalidArgumentError('invalid content-type header')) + return false + } + headers.push('content-type', contentTypeValue) + } } if (body && typeof body.read === 'function') { @@ -153526,6 +154015,28 @@ function calculateRetryAfterHeader (retryAfter) { return new Date(retryAfter).getTime() - current } +function validatePartialResponseContentLength (headers, range, statusCode, retryCount) { + const contentLength = headers['content-length'] + if (contentLength == null) { + return null + } + + if (!Number.isFinite(range.start) || !Number.isFinite(range.end)) { + return null + } + + const length = Number(contentLength) + const expectedLength = range.end - range.start + 1 + if (!Number.isFinite(length) || length !== expectedLength) { + return new RequestRetryError('Content-Length mismatch', statusCode, { + headers, + data: { count: retryCount } + }) + } + + return null +} + class RetryHandler { constructor (opts, handlers) { const { retryOptions, ...dispatchOpts } = opts @@ -153740,6 +154251,12 @@ class RetryHandler { return false } + const contentLengthError = validatePartialResponseContentLength(headers, contentRange, statusCode, this.retryCount) + if (contentLengthError != null) { + this.abort(contentLengthError) + return false + } + const { start, size, end = size - 1 } = contentRange assert(this.start === start, 'content-range mismatch') @@ -153763,6 +154280,12 @@ class RetryHandler { ) } + const contentLengthError = validatePartialResponseContentLength(headers, range, statusCode, this.retryCount) + if (contentLengthError != null) { + this.abort(contentLengthError) + return false + } + const { start, size, end = size - 1 } = range assert( start != null && Number.isFinite(start), @@ -158034,7 +158557,7 @@ function validateCookiePath (path) { if ( code < 0x20 || // exclude CTLs (0-31) - code === 0x7F || // DEL + code > 0x7E || // exclude DEL and non-ascii code === 0x3B // ; ) { throw new Error('Invalid cookie path') @@ -158043,16 +158566,80 @@ function validateCookiePath (path) { } /** - * I have no idea why these values aren't allowed to be honest, - * but Deno tests these. - Khafra + * ::= | + * + * ::= any one of the 52 alphabetic characters A through Z in + * upper case and a through z in lower case + * + * ::= any one of the ten digits 0 through 9r + * + * @see https://www.rfc-editor.org/rfc/rfc1034#section-3.5 + * @param {number} code + */ +function isLetterOrDigit (code) { + return ( + (code >= 0x30 && code <= 0x39) || // 0-9 + (code >= 0x41 && code <= 0x5A) || // A-Z + (code >= 0x61 && code <= 0x7A) // a-z + ) +} + +/** + * Validates a cookie domain against the "preferred name syntax". + * + * ::= | " " + * ::=