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.
- package/lib/utils.js +144 -62
- 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
|
-
|
|
116
|
-
|
|
117
|
-
let
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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
|
};
|