compressing 1.10.4 → 1.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/lib/utils.js +144 -62
  2. package/package.json +1 -1
package/lib/utils.js CHANGED
@@ -21,6 +21,63 @@ function isPathWithinParent(childPath, parentPath) {
21
21
  normalizedChild.startsWith(parentWithSep);
22
22
  }
23
23
 
24
+ /**
25
+ * Check if the real filesystem path stays within parentDir,
26
+ * accounting for pre-existing symlinks on disk.
27
+ * Walks each path segment from parentDir to targetPath using lstat.
28
+ * If any segment is a symlink, resolves it and verifies it stays within parentDir.
29
+ * @param {string} targetPath - Absolute path to validate
30
+ * @param {string} parentDir - Absolute path of the extraction root
31
+ * @param {string} realParentDir - Pre-resolved real path of parentDir (handles OS-level symlinks like /var -> /private/var on macOS)
32
+ * @param {function} callback - callback(err, safe)
33
+ */
34
+ function isRealPathSafe(targetPath, parentDir, realParentDir, callback) {
35
+ function isWithinParent(p) {
36
+ return isPathWithinParent(p, parentDir) || isPathWithinParent(p, realParentDir);
37
+ }
38
+
39
+ var relative = path.relative(parentDir, targetPath);
40
+ var segments = relative.split(path.sep);
41
+ var i = 0;
42
+ var current = parentDir;
43
+
44
+ function checkNext() {
45
+ if (i >= segments.length) return callback(null, true);
46
+ var segment = segments[i++];
47
+ if (!segment || segment === '.') return checkNext();
48
+
49
+ current = path.join(current, segment);
50
+ fs.lstat(current, function(err, stat) {
51
+ if (err) {
52
+ if (err.code === 'ENOENT') return callback(null, true); // doesn't exist yet, safe
53
+ // Fail closed: unexpected filesystem errors are unsafe
54
+ return callback(null, false);
55
+ }
56
+ if (!stat.isSymbolicLink()) return checkNext();
57
+
58
+ fs.realpath(current, function(err, resolved) {
59
+ if (err) {
60
+ if (err.code === 'ENOENT') {
61
+ // Dangling symlink - check textual target
62
+ return fs.readlink(current, function(err, linkTarget) {
63
+ if (err) return callback(null, false);
64
+ var absTarget = path.resolve(path.dirname(current), linkTarget);
65
+ callback(null, isWithinParent(absTarget));
66
+ });
67
+ }
68
+ // Fail closed: unexpected errors during symlink resolution are unsafe
69
+ return callback(null, false);
70
+ }
71
+ if (!isWithinParent(resolved)) return callback(null, false);
72
+ current = resolved;
73
+ checkNext();
74
+ });
75
+ });
76
+ }
77
+
78
+ checkNext();
79
+ }
80
+
24
81
  // file/fileBuffer/stream
25
82
  exports.sourceType = source => {
26
83
  if (!source) return undefined;
@@ -112,74 +169,99 @@ exports.makeUncompressFn = StreamClass => {
112
169
  // Resolve destDir to absolute path for security validation
113
170
  const resolvedDestDir = path.resolve(destDir);
114
171
 
115
- let entryCount = 0;
116
- let successCount = 0;
117
- let isFinish = false;
118
- function done() {
119
- // resolve when both stream finish and file write finish
120
- if (isFinish && entryCount === successCount) resolve();
121
- }
122
-
123
- new StreamClass(opts)
124
- .on('finish', () => {
125
- isFinish = true;
126
- done();
127
- })
128
- .on('error', reject)
129
- .on('entry', (header, stream, next) => {
130
- stream.on('end', next);
131
- const destFilePath = path.join(resolvedDestDir, header.name);
132
- const resolvedDestPath = path.resolve(destFilePath);
133
-
134
- // Security: Validate that the entry path doesn't escape the destination directory
135
- if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) {
136
- console.warn(`[compressing] Skipping entry with path traversal: "${header.name}" -> "${resolvedDestPath}"`);
137
- stream.resume();
138
- return;
139
- }
140
-
141
- if (header.type === 'file') {
142
- const dir = path.dirname(destFilePath);
143
- mkdirp(dir, err => {
144
- if (err) return reject(err);
145
-
146
- entryCount++;
147
- pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => {
148
- if (err) return reject(err);
149
- successCount++;
150
- done();
151
- });
152
- });
153
- } else if (header.type === 'symlink') {
154
- const dir = path.dirname(destFilePath);
155
- const target = path.resolve(dir, header.linkname);
156
-
157
- // Security: Validate that the symlink target doesn't escape the destination directory
158
- if (!isPathWithinParent(target, resolvedDestDir)) {
159
- console.warn(`[compressing] Skipping symlink "${header.name}": target "${target}" escapes extraction directory`);
172
+ // Resolve once for the entire extraction to handle OS-level symlinks
173
+ // (e.g. /var -> /private/var on macOS)
174
+ let realDestDir = resolvedDestDir;
175
+ fs.realpath(resolvedDestDir, (err, resolved) => {
176
+ if (!err) realDestDir = resolved;
177
+
178
+ let entryCount = 0;
179
+ let successCount = 0;
180
+ let isFinish = false;
181
+ function done() {
182
+ // resolve when both stream finish and file write finish
183
+ if (isFinish && entryCount === successCount) resolve();
184
+ }
185
+
186
+ new StreamClass(opts)
187
+ .on('finish', () => {
188
+ isFinish = true;
189
+ done();
190
+ })
191
+ .on('error', reject)
192
+ .on('entry', (header, stream, next) => {
193
+ stream.on('end', next);
194
+ const destFilePath = path.join(resolvedDestDir, header.name);
195
+ const resolvedDestPath = path.resolve(destFilePath);
196
+
197
+ // Security: Validate that the entry path doesn't escape the destination directory
198
+ if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) {
199
+ console.warn('[compressing] Skipping entry with path traversal: "' + header.name + '" -> "' + resolvedDestPath + '"');
160
200
  stream.resume();
161
201
  return;
162
202
  }
163
203
 
164
- entryCount++;
165
-
166
- mkdirp(dir, err => {
167
- if (err) return reject(err);
168
-
169
- const relativeTarget = path.relative(dir, target);
170
- fs.symlink(relativeTarget, destFilePath, err => {
171
- if (err) return reject(err);
172
- successCount++;
204
+ // Security: Validate no pre-existing symlink in the path escapes the extraction directory
205
+ isRealPathSafe(resolvedDestPath, resolvedDestDir, realDestDir, (err, safe) => {
206
+ if (err || !safe) {
207
+ console.warn('[compressing] Skipping entry "' + header.name + '": a symlink in its path resolves outside the extraction directory');
173
208
  stream.resume();
174
- });
175
- });
176
- } else { // directory
177
- mkdirp(destFilePath, err => {
178
- if (err) return reject(err);
179
- stream.resume();
209
+ return;
210
+ }
211
+
212
+ if (header.type === 'file') {
213
+ const dir = path.dirname(destFilePath);
214
+ mkdirp(dir, err => {
215
+ if (err) return reject(err);
216
+
217
+ entryCount++;
218
+ pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => {
219
+ if (err) return reject(err);
220
+ successCount++;
221
+ done();
222
+ });
223
+ });
224
+ } else if (header.type === 'symlink') {
225
+ const dir = path.dirname(destFilePath);
226
+ const target = path.resolve(dir, header.linkname);
227
+
228
+ // Security: Validate that the symlink target doesn't escape the destination directory
229
+ if (!isPathWithinParent(target, resolvedDestDir)) {
230
+ console.warn('[compressing] Skipping symlink "' + header.name + '": target "' + target + '" escapes extraction directory');
231
+ stream.resume();
232
+ return;
233
+ }
234
+
235
+ // Security: Validate no pre-existing symlink in the target path escapes the extraction directory
236
+ isRealPathSafe(target, resolvedDestDir, realDestDir, (err, targetSafe) => {
237
+ if (err || !targetSafe) {
238
+ console.warn('[compressing] Skipping symlink "' + header.name + '": target resolves outside extraction directory via existing symlink');
239
+ stream.resume();
240
+ return;
241
+ }
242
+
243
+ entryCount++;
244
+
245
+ mkdirp(dir, err => {
246
+ if (err) return reject(err);
247
+
248
+ const relativeTarget = path.relative(dir, target);
249
+ fs.symlink(relativeTarget, destFilePath, err => {
250
+ if (err) return reject(err);
251
+ successCount++;
252
+ stream.resume();
253
+ });
254
+ });
255
+ });
256
+ } else { // directory
257
+ mkdirp(destFilePath, err => {
258
+ if (err) return reject(err);
259
+ stream.resume();
260
+ });
261
+ }
180
262
  });
181
- }
182
- });
263
+ });
264
+ });
183
265
  });
184
266
  });
185
267
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compressing",
3
- "version": "1.10.4",
3
+ "version": "1.10.5",
4
4
  "description": "Everything you need for compressing and uncompressing",
5
5
  "main": "index.js",
6
6
  "scripts": {