compressing 2.0.1 → 2.1.1
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/README.md +4 -2
- package/lib/utils.js +113 -51
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,11 +8,11 @@
|
|
|
8
8
|

|
|
9
9
|
|
|
10
10
|
[npm-image]: https://img.shields.io/npm/v/compressing.svg?style=flat-square
|
|
11
|
-
[npm-url]: https://
|
|
11
|
+
[npm-url]: https://npmx.dev/package/compressing
|
|
12
12
|
[codecov-image]: https://codecov.io/gh/node-modules/compressing/branch/master/graph/badge.svg
|
|
13
13
|
[codecov-url]: https://codecov.io/gh/node-modules/compressing
|
|
14
14
|
[download-image]: https://img.shields.io/npm/dm/compressing.svg?style=flat-square
|
|
15
|
-
[download-url]: https://
|
|
15
|
+
[download-url]: https://npmx.dev/package/compressing
|
|
16
16
|
|
|
17
17
|
The missing compressing and uncompressing lib for node.
|
|
18
18
|
|
|
@@ -280,6 +280,7 @@ Params
|
|
|
280
280
|
- Korean: cp949, euc-kr
|
|
281
281
|
- Japanese: sjis (shift_jis), cp932, euc-jp
|
|
282
282
|
- Chinese: gbk, gb18030, gb2312, cp936, hkscs, big5, cp950
|
|
283
|
+
- opts.strip {Number} - Strip leading path segments when extracting (tar/tgz/zip). Default is 0.
|
|
283
284
|
|
|
284
285
|
### FileStream
|
|
285
286
|
|
|
@@ -359,6 +360,7 @@ __Constructor__
|
|
|
359
360
|
Common params:
|
|
360
361
|
|
|
361
362
|
- opts.source {String|Buffer|Stream} - source to be uncompressed, could be a file path, buffer, or a readable stream.
|
|
363
|
+
- opts.strip {Number} - Strip leading path segments when extracting (tar/tgz/zip). Default is 0.
|
|
362
364
|
|
|
363
365
|
__CAUTION for zip.UncompressStream__
|
|
364
366
|
|
package/lib/utils.js
CHANGED
|
@@ -20,6 +20,57 @@ function isPathWithinParent(childPath, parentPath) {
|
|
|
20
20
|
normalizedChild.startsWith(parentWithSep);
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Check if the real filesystem path stays within parentDir,
|
|
25
|
+
* accounting for pre-existing symlinks on disk.
|
|
26
|
+
* Walks each path segment from parentDir to targetPath using lstat.
|
|
27
|
+
* If any segment is a symlink, resolves it and verifies it stays within parentDir.
|
|
28
|
+
* @param {string} targetPath - Absolute path to validate
|
|
29
|
+
* @param {string} parentDir - Absolute path of the extraction root
|
|
30
|
+
* @param {string} realParentDir - Pre-resolved real path of parentDir (handles OS-level symlinks like /var -> /private/var on macOS)
|
|
31
|
+
* @returns {Promise<boolean>} true if safe, false if any segment escapes via symlink
|
|
32
|
+
*/
|
|
33
|
+
async function isRealPathSafe(targetPath, parentDir, realParentDir) {
|
|
34
|
+
function isWithinParent(p) {
|
|
35
|
+
return isPathWithinParent(p, parentDir) || isPathWithinParent(p, realParentDir);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const relative = path.relative(parentDir, targetPath);
|
|
39
|
+
const segments = relative.split(path.sep);
|
|
40
|
+
let current = parentDir;
|
|
41
|
+
for (const segment of segments) {
|
|
42
|
+
if (!segment || segment === '.') continue;
|
|
43
|
+
current = path.join(current, segment);
|
|
44
|
+
try {
|
|
45
|
+
const stat = await fs.promises.lstat(current);
|
|
46
|
+
if (stat.isSymbolicLink()) {
|
|
47
|
+
let resolved;
|
|
48
|
+
try {
|
|
49
|
+
resolved = await fs.promises.realpath(current);
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e.code === 'ENOENT') {
|
|
52
|
+
// Dangling symlink - check textual target
|
|
53
|
+
const linkTarget = await fs.promises.readlink(current);
|
|
54
|
+
const absTarget = path.resolve(path.dirname(current), linkTarget);
|
|
55
|
+
return isWithinParent(absTarget);
|
|
56
|
+
}
|
|
57
|
+
// Fail closed: unexpected errors during symlink resolution are unsafe
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
if (!isWithinParent(resolved)) {
|
|
61
|
+
return false;
|
|
62
|
+
}
|
|
63
|
+
current = resolved;
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
if (e.code === 'ENOENT') break; // Path doesn't exist yet, safe
|
|
67
|
+
// Fail closed: unexpected filesystem errors are unsafe
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
23
74
|
// file/fileBuffer/stream
|
|
24
75
|
exports.sourceType = source => {
|
|
25
76
|
if (!source) return undefined;
|
|
@@ -104,12 +155,19 @@ exports.makeUncompressFn = StreamClass => {
|
|
|
104
155
|
throw error;
|
|
105
156
|
}
|
|
106
157
|
|
|
158
|
+
const strip = opts.strip ? Number(opts.strip) : 0;
|
|
159
|
+
// Strip is handled here in makeUncompressFn, so remove it from opts to avoid passing to UncompressStream
|
|
160
|
+
delete opts.strip;
|
|
161
|
+
|
|
107
162
|
return new Promise((resolve, reject) => {
|
|
108
163
|
fs.mkdir(destDir, { recursive: true }, err => {
|
|
109
164
|
if (err) return reject(err);
|
|
110
165
|
|
|
111
166
|
// Resolve destDir to absolute path for security validation
|
|
112
167
|
const resolvedDestDir = path.resolve(destDir);
|
|
168
|
+
// Resolve once for the entire extraction to handle OS-level symlinks
|
|
169
|
+
// (e.g. /var -> /private/var on macOS)
|
|
170
|
+
const realDestDirPromise = fs.promises.realpath(resolvedDestDir).catch(() => resolvedDestDir);
|
|
113
171
|
|
|
114
172
|
let entryCount = 0;
|
|
115
173
|
let successCount = 0;
|
|
@@ -119,6 +177,60 @@ exports.makeUncompressFn = StreamClass => {
|
|
|
119
177
|
if (isFinish && entryCount === successCount) resolve();
|
|
120
178
|
}
|
|
121
179
|
|
|
180
|
+
async function processEntry(header, stream) {
|
|
181
|
+
const destFilePath = path.join(resolvedDestDir, stripFileName(strip, header.name, header.type));
|
|
182
|
+
const resolvedDestPath = path.resolve(destFilePath);
|
|
183
|
+
|
|
184
|
+
if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) {
|
|
185
|
+
console.warn(`[compressing] Skipping entry with path traversal: "${header.name}" -> "${resolvedDestPath}"`);
|
|
186
|
+
stream.resume();
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const realDestDir = await realDestDirPromise;
|
|
191
|
+
if (!await isRealPathSafe(resolvedDestPath, resolvedDestDir, realDestDir)) {
|
|
192
|
+
console.warn(`[compressing] Skipping entry "${header.name}": a symlink in its path resolves outside the extraction directory`);
|
|
193
|
+
stream.resume();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (header.type === 'file') {
|
|
198
|
+
const dir = path.dirname(destFilePath);
|
|
199
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
200
|
+
entryCount++;
|
|
201
|
+
pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => {
|
|
202
|
+
if (err) return reject(err);
|
|
203
|
+
successCount++;
|
|
204
|
+
done();
|
|
205
|
+
});
|
|
206
|
+
} else if (header.type === 'symlink') {
|
|
207
|
+
const dir = path.dirname(destFilePath);
|
|
208
|
+
const target = path.resolve(dir, header.linkname);
|
|
209
|
+
|
|
210
|
+
if (!isPathWithinParent(target, resolvedDestDir)) {
|
|
211
|
+
console.warn(`[compressing] Skipping symlink "${header.name}": target "${target}" escapes extraction directory`);
|
|
212
|
+
stream.resume();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (!await isRealPathSafe(target, resolvedDestDir, realDestDir)) {
|
|
217
|
+
console.warn(`[compressing] Skipping symlink "${header.name}": target resolves outside extraction directory via existing symlink`);
|
|
218
|
+
stream.resume();
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
entryCount++;
|
|
223
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
224
|
+
const relativeTarget = path.relative(dir, target);
|
|
225
|
+
await fs.promises.symlink(relativeTarget, destFilePath);
|
|
226
|
+
successCount++;
|
|
227
|
+
stream.resume();
|
|
228
|
+
} else { // directory
|
|
229
|
+
await fs.promises.mkdir(destFilePath, { recursive: true });
|
|
230
|
+
stream.resume();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
122
234
|
new StreamClass(opts)
|
|
123
235
|
.on('finish', () => {
|
|
124
236
|
isFinish = true;
|
|
@@ -127,57 +239,7 @@ exports.makeUncompressFn = StreamClass => {
|
|
|
127
239
|
.on('error', reject)
|
|
128
240
|
.on('entry', (header, stream, next) => {
|
|
129
241
|
stream.on('end', next);
|
|
130
|
-
|
|
131
|
-
const resolvedDestPath = path.resolve(destFilePath);
|
|
132
|
-
|
|
133
|
-
// Security: Validate that the entry path doesn't escape the destination directory
|
|
134
|
-
if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) {
|
|
135
|
-
console.warn(`[compressing] Skipping entry with path traversal: "${header.name}" -> "${resolvedDestPath}"`);
|
|
136
|
-
stream.resume();
|
|
137
|
-
return;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
if (header.type === 'file') {
|
|
141
|
-
const dir = path.dirname(destFilePath);
|
|
142
|
-
fs.mkdir(dir, { recursive: true }, err => {
|
|
143
|
-
if (err) return reject(err);
|
|
144
|
-
|
|
145
|
-
entryCount++;
|
|
146
|
-
pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => {
|
|
147
|
-
if (err) return reject(err);
|
|
148
|
-
successCount++;
|
|
149
|
-
done();
|
|
150
|
-
});
|
|
151
|
-
});
|
|
152
|
-
} else if (header.type === 'symlink') {
|
|
153
|
-
const dir = path.dirname(destFilePath);
|
|
154
|
-
const target = path.resolve(dir, header.linkname);
|
|
155
|
-
|
|
156
|
-
// Security: Validate that the symlink target doesn't escape the destination directory
|
|
157
|
-
if (!isPathWithinParent(target, resolvedDestDir)) {
|
|
158
|
-
console.warn(`[compressing] Skipping symlink "${header.name}": target "${target}" escapes extraction directory`);
|
|
159
|
-
stream.resume();
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
entryCount++;
|
|
164
|
-
|
|
165
|
-
fs.mkdir(dir, { recursive: true }, err => {
|
|
166
|
-
if (err) return reject(err);
|
|
167
|
-
|
|
168
|
-
const relativeTarget = path.relative(dir, target);
|
|
169
|
-
fs.symlink(relativeTarget, destFilePath, err => {
|
|
170
|
-
if (err) return reject(err);
|
|
171
|
-
successCount++;
|
|
172
|
-
stream.resume();
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
} else { // directory
|
|
176
|
-
fs.mkdir(destFilePath, { recursive: true }, err => {
|
|
177
|
-
if (err) return reject(err);
|
|
178
|
-
stream.resume();
|
|
179
|
-
});
|
|
180
|
-
}
|
|
242
|
+
processEntry(header, stream).catch(reject);
|
|
181
243
|
});
|
|
182
244
|
});
|
|
183
245
|
});
|