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.
Files changed (3) hide show
  1. package/README.md +4 -2
  2. package/lib/utils.js +113 -51
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -8,11 +8,11 @@
8
8
  ![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/node-modules/compressing)
9
9
 
10
10
  [npm-image]: https://img.shields.io/npm/v/compressing.svg?style=flat-square
11
- [npm-url]: https://npmjs.org/package/compressing
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://npmjs.org/package/compressing
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
- const destFilePath = path.join(resolvedDestDir, header.name);
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compressing",
3
- "version": "2.0.1",
3
+ "version": "2.1.1",
4
4
  "description": "Everything you need for compressing and uncompressing",
5
5
  "main": "index.js",
6
6
  "scripts": {