compressing 2.1.0 → 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 +2 -2
- package/lib/utils.js +109 -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
|
|
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;
|
|
@@ -114,6 +165,9 @@ exports.makeUncompressFn = StreamClass => {
|
|
|
114
165
|
|
|
115
166
|
// Resolve destDir to absolute path for security validation
|
|
116
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);
|
|
117
171
|
|
|
118
172
|
let entryCount = 0;
|
|
119
173
|
let successCount = 0;
|
|
@@ -123,6 +177,60 @@ exports.makeUncompressFn = StreamClass => {
|
|
|
123
177
|
if (isFinish && entryCount === successCount) resolve();
|
|
124
178
|
}
|
|
125
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
|
+
|
|
126
234
|
new StreamClass(opts)
|
|
127
235
|
.on('finish', () => {
|
|
128
236
|
isFinish = true;
|
|
@@ -131,57 +239,7 @@ exports.makeUncompressFn = StreamClass => {
|
|
|
131
239
|
.on('error', reject)
|
|
132
240
|
.on('entry', (header, stream, next) => {
|
|
133
241
|
stream.on('end', next);
|
|
134
|
-
|
|
135
|
-
const resolvedDestPath = path.resolve(destFilePath);
|
|
136
|
-
|
|
137
|
-
// Security: Validate that the entry path doesn't escape the destination directory
|
|
138
|
-
if (!isPathWithinParent(resolvedDestPath, resolvedDestDir)) {
|
|
139
|
-
console.warn(`[compressing] Skipping entry with path traversal: "${header.name}" -> "${resolvedDestPath}"`);
|
|
140
|
-
stream.resume();
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
if (header.type === 'file') {
|
|
145
|
-
const dir = path.dirname(destFilePath);
|
|
146
|
-
fs.mkdir(dir, { recursive: true }, err => {
|
|
147
|
-
if (err) return reject(err);
|
|
148
|
-
|
|
149
|
-
entryCount++;
|
|
150
|
-
pump(stream, fs.createWriteStream(destFilePath, { mode: opts.mode || header.mode }), err => {
|
|
151
|
-
if (err) return reject(err);
|
|
152
|
-
successCount++;
|
|
153
|
-
done();
|
|
154
|
-
});
|
|
155
|
-
});
|
|
156
|
-
} else if (header.type === 'symlink') {
|
|
157
|
-
const dir = path.dirname(destFilePath);
|
|
158
|
-
const target = path.resolve(dir, header.linkname);
|
|
159
|
-
|
|
160
|
-
// Security: Validate that the symlink target doesn't escape the destination directory
|
|
161
|
-
if (!isPathWithinParent(target, resolvedDestDir)) {
|
|
162
|
-
console.warn(`[compressing] Skipping symlink "${header.name}": target "${target}" escapes extraction directory`);
|
|
163
|
-
stream.resume();
|
|
164
|
-
return;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
entryCount++;
|
|
168
|
-
|
|
169
|
-
fs.mkdir(dir, { recursive: true }, err => {
|
|
170
|
-
if (err) return reject(err);
|
|
171
|
-
|
|
172
|
-
const relativeTarget = path.relative(dir, target);
|
|
173
|
-
fs.symlink(relativeTarget, destFilePath, err => {
|
|
174
|
-
if (err) return reject(err);
|
|
175
|
-
successCount++;
|
|
176
|
-
stream.resume();
|
|
177
|
-
});
|
|
178
|
-
});
|
|
179
|
-
} else { // directory
|
|
180
|
-
fs.mkdir(destFilePath, { recursive: true }, err => {
|
|
181
|
-
if (err) return reject(err);
|
|
182
|
-
stream.resume();
|
|
183
|
-
});
|
|
184
|
-
}
|
|
242
|
+
processEntry(header, stream).catch(reject);
|
|
185
243
|
});
|
|
186
244
|
});
|
|
187
245
|
});
|