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 +15 -4
- package/dist/fs/index.d.ts +9 -1
- package/dist/fs/index.js +78 -22
- package/dist/{index-Dx4tbuJh.d.ts → index-G8Ie88oV.d.ts} +2 -2
- package/dist/web/index.d.ts +1 -1
- package/dist/web/index.js +1 -1
- package/dist/{web-C1b5fAZt.js → web-DcwR3pag.js} +40 -26
- package/package.json +2 -2
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
package/dist/fs/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { TarEntryData, TarHeader, UnpackOptions } from "../index-
|
|
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-
|
|
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
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
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
|
|
306
|
-
if (
|
|
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
|
|
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
|
/**
|
package/dist/web/index.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { ParsedTarEntry, ParsedTarEntryWithData, TarEntry, TarEntryData, TarHeader, TarPackController, UnpackOptions, createGzipDecoder, createGzipEncoder, createTarDecoder, createTarOptionsTransformer, createTarPacker, packTar, unpackTar } from "../index-
|
|
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-
|
|
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
|
-
|
|
100
|
-
|
|
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
|
-
|
|
104
|
+
return;
|
|
103
105
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 =
|
|
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
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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.
|
|
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
|
},
|