modern-tar 0.2.0 → 0.2.2

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 CHANGED
@@ -59,7 +59,8 @@ const entries = [
59
59
  const tarBuffer = await packTar(entries);
60
60
 
61
61
  // Unpack tar buffer into entries
62
- for await (const entry of unpackTar(tarBuffer)) {
62
+ const entries = await unpackTar(tarBuffer);
63
+ for (const entry of entries) {
63
64
  console.log(`File: ${entry.header.name}`);
64
65
  const content = new TextDecoder().decode(entry.data);
65
66
  console.log(`Content: ${content}`);
@@ -114,7 +115,8 @@ if (!response.body) throw new Error('No response body');
114
115
  const tarStream = response.body.pipeThrough(createGzipDecoder());
115
116
 
116
117
  // Use `unpackTar` for buffered extraction or `createTarDecoder` for streaming
117
- for await (const entry of unpackTar(tarStream)) {
118
+ const entries = await unpackTar(tarStream);
119
+ for (const entry of entries) {
118
120
  console.log(`Extracted: ${entry.header.name}`);
119
121
  const content = new TextDecoder().decode(entry.data);
120
122
  console.log(`Content: ${content}`);
@@ -167,7 +169,8 @@ const extractStream = unpackTar('./output', {
167
169
 
168
170
  // Filesystem-specific options
169
171
  fmode: 0o644, // Override file permissions
170
- dmode: 0o755 // Override directory permissions
172
+ dmode: 0o755, // Override directory permissions
173
+ maxDepth: 50 // Limit extraction depth for security (default: 1024)
171
174
  });
172
175
 
173
176
  await pipeline(sourceStream, extractStream);
@@ -418,7 +421,7 @@ interface ParsedTarEntry {
418
421
  // Output entry from a buffered unpack function
419
422
  interface ParsedTarEntryWithData {
420
423
  header: TarHeader;
421
- data: Uint8Array;
424
+ data: Uint8Array<ArrayBuffer>;
422
425
  }
423
426
 
424
427
  // Platform-neutral configuration for unpacking
@@ -492,6 +495,14 @@ interface UnpackOptionsFS extends UnpackOptions {
492
495
  * @default true
493
496
  */
494
497
  validateSymlinks?: boolean;
498
+ /**
499
+ * The maximum depth of paths to extract. Prevents Denial of Service (DoS) attacks
500
+ * from malicious archives with deeply nested directories.
501
+ *
502
+ * Set to `Infinity` to disable depth checking (not recommended for untrusted archives).
503
+ * @default 1024
504
+ */
505
+ maxDepth?: number;
495
506
  }
496
507
  ```
497
508
 
@@ -1,4 +1,4 @@
1
- import { TarEntryData, TarHeader, UnpackOptions } from "../index-Dx4tbuJh.js";
1
+ import { TarEntryData, TarHeader, UnpackOptions } from "../index-G8Ie88oV.js";
2
2
  import { Stats } from "node:fs";
3
3
  import { Readable, Writable } from "node:stream";
4
4
 
@@ -34,6 +34,14 @@ interface UnpackOptionsFS extends UnpackOptions {
34
34
  * @default true
35
35
  */
36
36
  validateSymlinks?: boolean;
37
+ /**
38
+ * The maximum depth of paths to extract. Prevents Denial of Service (DoS) attacks
39
+ * from malicious archives with deeply nested directories.
40
+ *
41
+ * Set to `Infinity` to disable depth checking (not recommended for untrusted archives).
42
+ * @default 1024
43
+ */
44
+ maxDepth?: number;
37
45
  }
38
46
  /** Describes a file on the local filesystem to be added to the archive. */
39
47
  interface FileSource {
package/dist/fs/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { BLOCK_SIZE, createTarDecoder, createTarHeader, createTarOptionsTransformer, createTarPacker } from "../web-C1b5fAZt.js";
1
+ import { BLOCK_SIZE, createTarDecoder, createTarHeader, createTarOptionsTransformer, createTarPacker } from "../web-DcwR3pag.js";
2
2
  import { createReadStream, createWriteStream } from "node:fs";
3
3
  import * as fs from "node:fs/promises";
4
4
  import * as path from "node:path";
@@ -258,35 +258,35 @@ function unpackTar(directoryPath, options = {}) {
258
258
  }
259
259
  });
260
260
  const processingPromise = (async () => {
261
- const resolvedDestDir = path.resolve(directoryPath);
262
- const createdDirs = /* @__PURE__ */ new Set();
261
+ const resolvedDestDir = normalizePath(path.resolve(directoryPath));
262
+ const validatedDirs = new Set([resolvedDestDir]);
263
263
  await fs.mkdir(resolvedDestDir, { recursive: true });
264
- createdDirs.add(resolvedDestDir);
265
264
  const reader = readable.pipeThrough(createTarDecoder()).pipeThrough(createTarOptionsTransformer(options)).getReader();
266
265
  try {
266
+ const maxDepth = options.maxDepth ?? 1024;
267
267
  while (true) {
268
268
  const { done, value: entry } = await reader.read();
269
269
  if (done) break;
270
270
  const { header } = entry;
271
- if (path.isAbsolute(header.name)) throw new Error(`Path traversal attempt detected for entry "${header.name}".`);
272
- const outPath = path.join(resolvedDestDir, header.name);
273
- if (!outPath.startsWith(resolvedDestDir)) throw new Error(`Path traversal attempt detected for entry "${header.name}".`);
274
- const parentDir = path.dirname(outPath);
275
- if (!createdDirs.has(parentDir)) {
276
- await fs.mkdir(parentDir, { recursive: true });
277
- createdDirs.add(parentDir);
271
+ const normalizedName = normalizePath(header.name);
272
+ if (maxDepth !== Infinity) {
273
+ const depth = normalizedName.split("/").length;
274
+ if (depth > maxDepth) throw new Error(`Path depth of entry "${header.name}" (${depth}) exceeds the maximum allowed depth of ${maxDepth}.`);
278
275
  }
276
+ if (path.isAbsolute(normalizedName)) throw new Error(`Path traversal attempt detected for entry "${header.name}".`);
277
+ const outPath = path.join(resolvedDestDir, normalizedName);
278
+ validateBounds(outPath, resolvedDestDir, `Path traversal attempt detected for entry "${header.name}".`);
279
+ const parentDir = path.dirname(outPath);
280
+ await validatePath(parentDir, resolvedDestDir, validatedDirs);
281
+ await fs.mkdir(parentDir, { recursive: true });
279
282
  switch (header.type) {
280
283
  case "directory": {
281
284
  const mode = options.dmode ?? header.mode;
282
- if (createdDirs.has(outPath) && mode) await fs.chmod(outPath, mode);
283
- else {
284
- await fs.mkdir(outPath, {
285
- recursive: true,
286
- mode
287
- });
288
- createdDirs.add(outPath);
289
- }
285
+ await fs.mkdir(outPath, {
286
+ recursive: true,
287
+ mode
288
+ });
289
+ validatedDirs.add(outPath);
290
290
  break;
291
291
  }
292
292
  case "file":
@@ -296,14 +296,22 @@ function unpackTar(directoryPath, options = {}) {
296
296
  if (!header.linkname) break;
297
297
  if (options.validateSymlinks ?? true) {
298
298
  const symlinkDir = path.dirname(outPath);
299
- if (!path.resolve(symlinkDir, header.linkname).startsWith(resolvedDestDir)) throw new Error(`Symlink target "${header.linkname}" points outside the extraction directory.`);
299
+ const resolvedTarget = path.resolve(symlinkDir, header.linkname);
300
+ validateBounds(resolvedTarget, resolvedDestDir, `Symlink target "${header.linkname}" points outside the extraction directory.`);
300
301
  }
301
302
  await fs.symlink(header.linkname, outPath);
303
+ if (process.platform === "win32") {
304
+ validatedDirs.clear();
305
+ validatedDirs.add(resolvedDestDir);
306
+ } else validatedDirs.delete(outPath);
302
307
  break;
303
308
  case "link": {
304
309
  if (!header.linkname) break;
305
- const resolvedLinkTarget = path.resolve(resolvedDestDir, header.linkname);
306
- if (!resolvedLinkTarget.startsWith(resolvedDestDir)) throw new Error(`Hardlink target "${header.linkname}" points outside the extraction directory.`);
310
+ const normalizedLinkname = normalizePath(header.linkname);
311
+ if (path.isAbsolute(normalizedLinkname)) throw new Error(`Hardlink target "${header.linkname}" points outside the extraction directory.`);
312
+ const resolvedLinkTarget = path.resolve(resolvedDestDir, normalizedLinkname);
313
+ validateBounds(resolvedLinkTarget, resolvedDestDir, `Hardlink target "${header.linkname}" points outside the extraction directory.`);
314
+ await validatePath(path.dirname(resolvedLinkTarget), resolvedDestDir, validatedDirs);
307
315
  await fs.link(resolvedLinkTarget, outPath);
308
316
  break;
309
317
  }
@@ -321,6 +329,54 @@ function unpackTar(directoryPath, options = {}) {
321
329
  });
322
330
  return writable;
323
331
  }
332
+ /**
333
+ * Recursively validates that each item of the given path exists and is a directory or
334
+ * a safe symlink.
335
+ *
336
+ * We need to call this for each path component to ensure that no symlinks escape the
337
+ * target directory.
338
+ */
339
+ async function validatePath(currentPath, root, cache) {
340
+ const normalizedPath = normalizePath(currentPath);
341
+ if (normalizedPath === root || cache.has(normalizedPath)) return;
342
+ let stat;
343
+ try {
344
+ stat = await fs.lstat(normalizedPath);
345
+ } catch (err) {
346
+ if (err instanceof Error && "code" in err && (err.code === "ENOENT" || err.code === "EPERM")) {
347
+ await validatePath(path.dirname(normalizedPath), root, cache);
348
+ cache.add(normalizedPath);
349
+ return;
350
+ }
351
+ throw err;
352
+ }
353
+ if (stat.isDirectory()) {
354
+ await validatePath(path.dirname(normalizedPath), root, cache);
355
+ cache.add(normalizedPath);
356
+ return;
357
+ }
358
+ if (stat.isSymbolicLink()) {
359
+ const realPath = await fs.realpath(normalizedPath);
360
+ validateBounds(realPath, root, `Path traversal attempt detected: symlink "${currentPath}" points outside the extraction directory.`);
361
+ await validatePath(path.dirname(normalizedPath), root, cache);
362
+ cache.add(normalizedPath);
363
+ return;
364
+ }
365
+ throw new Error(`Path traversal attempt detected: "${currentPath}" is not a valid directory component.`);
366
+ }
367
+ function validateBounds(targetPath, destDir, errorMessage) {
368
+ const normalizedTarget = normalizePath(targetPath);
369
+ if (!(normalizedTarget === destDir || normalizedTarget.startsWith(destDir + path.sep))) throw new Error(errorMessage);
370
+ }
371
+ /**
372
+ * Normalizes a file path to prevent Unicode-based security vulnerabilities.
373
+ *
374
+ * This prevents cache poisoning attacks where different Unicode representations
375
+ * of the same visual path (e.g., "café" vs "cafe´") could bypass validation.
376
+ */
377
+ function normalizePath(pathStr) {
378
+ return pathStr.normalize("NFKD");
379
+ }
324
380
 
325
381
  //#endregion
326
382
  export { packTar, packTarSources, unpackTar };
@@ -131,7 +131,7 @@ interface ParsedTarEntry {
131
131
  */
132
132
  interface ParsedTarEntryWithData {
133
133
  header: TarHeader;
134
- data: Uint8Array;
134
+ data: Uint8Array<ArrayBuffer>;
135
135
  }
136
136
  /**
137
137
  * Platform-neutral configuration options for extracting tar archives.
@@ -227,7 +227,7 @@ declare function packTar(entries: TarEntry[]): Promise<Uint8Array>;
227
227
  * }
228
228
  * ```
229
229
  */
230
- declare function unpackTar(archive: ArrayBuffer | Uint8Array, options?: UnpackOptions): Promise<ParsedTarEntryWithData[]>;
230
+ declare function unpackTar(archive: ArrayBuffer | Uint8Array | ReadableStream<Uint8Array>, options?: UnpackOptions): Promise<ParsedTarEntryWithData[]>;
231
231
  //#endregion
232
232
  //#region src/web/options.d.ts
233
233
  /**
@@ -1,2 +1,2 @@
1
- import { ParsedTarEntry, ParsedTarEntryWithData, TarEntry, TarEntryData, TarHeader, TarPackController, UnpackOptions, createGzipDecoder, createGzipEncoder, createTarDecoder, createTarOptionsTransformer, createTarPacker, packTar, unpackTar } from "../index-Dx4tbuJh.js";
1
+ import { ParsedTarEntry, ParsedTarEntryWithData, TarEntry, TarEntryData, TarHeader, TarPackController, UnpackOptions, createGzipDecoder, createGzipEncoder, createTarDecoder, createTarOptionsTransformer, createTarPacker, packTar, unpackTar } from "../index-G8Ie88oV.js";
2
2
  export { ParsedTarEntry, ParsedTarEntryWithData, TarEntry, TarEntryData, TarHeader, TarPackController, UnpackOptions, createGzipDecoder, createGzipEncoder, createTarDecoder, createTarOptionsTransformer, createTarPacker, packTar, unpackTar };
package/dist/web/index.js CHANGED
@@ -1,3 +1,3 @@
1
- import { createGzipDecoder, createGzipEncoder, createTarDecoder, createTarOptionsTransformer, createTarPacker, packTar, unpackTar } from "../web-C1b5fAZt.js";
1
+ import { createGzipDecoder, createGzipEncoder, createTarDecoder, createTarOptionsTransformer, createTarPacker, packTar, unpackTar } from "../web-DcwR3pag.js";
2
2
 
3
3
  export { createGzipDecoder, createGzipEncoder, createTarDecoder, createTarOptionsTransformer, createTarPacker, packTar, unpackTar };
@@ -96,27 +96,23 @@ function createGzipDecoder() {
96
96
  function createTarOptionsTransformer(options = {}) {
97
97
  return new TransformStream({ async transform(entry, controller) {
98
98
  let header = entry.header;
99
- if (options.strip !== void 0) {
100
- if (options.strip < 0) {
99
+ const stripCount = options.strip;
100
+ if (stripCount && stripCount > 0) {
101
+ const newName = stripPathComponents(header.name, stripCount);
102
+ if (newName === null) {
101
103
  drainStream(entry.body);
102
- throw new Error(`Invalid strip value: ${options.strip}. Must be non-negative.`);
104
+ return;
103
105
  }
104
- if (options.strip > 0) {
105
- const strippedComponents = header.name.split("/").filter((component) => component.length > 0).slice(options.strip);
106
- if (strippedComponents.length === 0) {
107
- drainStream(entry.body);
108
- return;
109
- }
110
- const strippedName = strippedComponents.join("/");
111
- if (header.type === "directory" && !strippedName.endsWith("/")) header = {
112
- ...header,
113
- name: `${strippedName}/`
114
- };
115
- else header = {
116
- ...header,
117
- name: strippedName
118
- };
106
+ let newLinkname = header.linkname;
107
+ if (newLinkname?.startsWith("/")) {
108
+ const strippedLinkTarget = stripPathComponents(newLinkname, stripCount);
109
+ newLinkname = strippedLinkTarget === null ? "/" : `/${strippedLinkTarget}`;
119
110
  }
111
+ header = {
112
+ ...header,
113
+ name: header.type === "directory" && !newName.endsWith("/") ? `${newName}/` : newName,
114
+ linkname: newLinkname
115
+ };
120
116
  }
121
117
  if (options.filter && options.filter(header) === false) {
122
118
  drainStream(entry.body);
@@ -130,6 +126,14 @@ function createTarOptionsTransformer(options = {}) {
130
126
  } });
131
127
  }
132
128
  /**
129
+ * Strips the specified number of leading path components from a given path.
130
+ */
131
+ function stripPathComponents(path, stripCount) {
132
+ const components = path.split("/").filter((c) => c.length > 0);
133
+ if (stripCount >= components.length) return null;
134
+ return components.slice(stripCount).join("/");
135
+ }
136
+ /**
133
137
  * Drains the stream asynchronously without blocking the transform stream.
134
138
  */
135
139
  function drainStream(stream) {
@@ -694,15 +698,25 @@ async function packTar(entries) {
694
698
  * ```
695
699
  */
696
700
  async function unpackTar(archive, options = {}) {
697
- const sourceStream = ReadableStream.from([archive instanceof Uint8Array ? archive : new Uint8Array(archive)]);
701
+ const sourceStream = archive instanceof ReadableStream ? archive : new ReadableStream({ start(controller) {
702
+ const data = archive instanceof Uint8Array ? archive : new Uint8Array(archive);
703
+ controller.enqueue(data);
704
+ controller.close();
705
+ } });
698
706
  const results = [];
699
- const entryStream = sourceStream.pipeThrough(createTarDecoder()).pipeThrough(createTarOptionsTransformer(options));
700
- for await (const entry of entryStream) {
701
- const data = new Uint8Array(await new Response(entry.body).arrayBuffer());
702
- results.push({
703
- header: entry.header,
704
- data
705
- });
707
+ const reader = sourceStream.pipeThrough(createTarDecoder()).pipeThrough(createTarOptionsTransformer(options)).getReader();
708
+ try {
709
+ while (true) {
710
+ const { done, value: entry } = await reader.read();
711
+ if (done) break;
712
+ const data = new Uint8Array(await new Response(entry.body).arrayBuffer());
713
+ results.push({
714
+ header: entry.header,
715
+ data
716
+ });
717
+ }
718
+ } finally {
719
+ reader.releaseLock();
706
720
  }
707
721
  return results;
708
722
  }
package/package.json CHANGED
@@ -1,7 +1,8 @@
1
1
  {
2
2
  "name": "modern-tar",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Zero dependency streaming tar parser and writer for JavaScript.",
5
+ "author": "Ayuhito <hello@ayuhito.com>",
5
6
  "license": "MIT",
6
7
  "type": "module",
7
8
  "sideEffects": false,
@@ -48,7 +49,6 @@
48
49
  "type": "git",
49
50
  "url": "git+https://github.com/ayuhito/modern-tar.git"
50
51
  },
51
- "author": "Ayuhito <hello@ayuhito.com>",
52
52
  "publishConfig": {
53
53
  "access": "public"
54
54
  },