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.
Files changed (3) hide show
  1. package/README.md +2 -2
  2. package/lib/utils.js +109 -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
 
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
- const destFilePath = path.join(resolvedDestDir, stripFileName(strip, header.name, header.type));
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
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "compressing",
3
- "version": "2.1.0",
3
+ "version": "2.1.1",
4
4
  "description": "Everything you need for compressing and uncompressing",
5
5
  "main": "index.js",
6
6
  "scripts": {