w3wallets 0.10.2 → 1.0.0-beta.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.
@@ -1,81 +1,307 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- *
5
- * Downloads and extracts Chrome extensions by alias ("backpack" and "metamask")
4
+ * Downloads and extracts Chrome extensions from the Chrome Web Store.
6
5
  *
7
6
  * Usage:
8
- * npx w3wallets backpack
9
- * npx w3wallets metamask
10
- * npx w3wallets backpack metamask
7
+ * npx w3wallets metamask backpack # Download by alias
8
+ * npx w3wallets mm bp pjs # Short aliases
9
+ * npx w3wallets <extension-id> # Download by extension ID
10
+ * npx w3wallets --help # Show help
11
11
  */
12
12
 
13
13
  const fs = require("fs");
14
14
  const https = require("https");
15
15
  const path = require("path");
16
- const url = require("url");
17
16
  const zlib = require("zlib");
18
17
 
19
18
  // ---------------------------------------------------------------------
20
- // 1. Known aliases -> extension IDs
19
+ // 1. Known aliases -> extension IDs (case-insensitive lookup)
21
20
  // ---------------------------------------------------------------------
22
- const ALIASES = {
21
+ const EXTENSION_REGISTRY = {
22
+ // Backpack wallet
23
23
  backpack: "aflkmfhebedbjioipglgcbcmnbpgliof",
24
+ bp: "aflkmfhebedbjioipglgcbcmnbpgliof",
25
+
26
+ // MetaMask wallet
24
27
  metamask: "nkbihfbeogaeaoehlefnkodbefgpgknn",
25
- polkadotJS: "mopnmbcafieddcagagdcbnhejhlodfdd",
28
+ mm: "nkbihfbeogaeaoehlefnkodbefgpgknn",
29
+
30
+ // Polkadot.js wallet
31
+ polkadotjs: "mopnmbcafieddcagagdcbnhejhlodfdd",
32
+ pjs: "mopnmbcafieddcagagdcbnhejhlodfdd",
26
33
  };
27
34
 
35
+ // Human-readable names for display
36
+ const EXTENSION_NAMES = {
37
+ aflkmfhebedbjioipglgcbcmnbpgliof: "Backpack",
38
+ nkbihfbeogaeaoehlefnkodbefgpgknn: "MetaMask",
39
+ mopnmbcafieddcagagdcbnhejhlodfdd: "Polkadot.js",
40
+ };
41
+
42
+ // Canonical aliases for listing
43
+ const CANONICAL_ALIASES = [
44
+ { name: "backpack", short: "bp", id: "aflkmfhebedbjioipglgcbcmnbpgliof" },
45
+ { name: "metamask", short: "mm", id: "nkbihfbeogaeaoehlefnkodbefgpgknn" },
46
+ { name: "polkadotjs", short: "pjs", id: "mopnmbcafieddcagagdcbnhejhlodfdd" },
47
+ ];
48
+
28
49
  // ---------------------------------------------------------------------
29
- // 2. Read aliases from CLI
50
+ // ZIP format constants (per PKWARE APPNOTE.TXT specification)
30
51
  // ---------------------------------------------------------------------
31
- const inputAliases = process.argv.slice(2);
52
+ const ZIP_SIGNATURES = {
53
+ EOCD: 0x06054b50, // End of Central Directory
54
+ CENTRAL_DIR: 0x02014b50, // Central Directory file header
55
+ LOCAL_FILE: 0x04034b50, // Local file header
56
+ };
32
57
 
33
- if (!inputAliases.length) {
34
- console.error("Usage: npx w3wallets <aliases...>");
35
- console.error("Available aliases:", Object.keys(ALIASES).join(", "));
36
- process.exit(1);
58
+ const ZIP_FLAGS = {
59
+ ENCRYPTED: 0x0001, // File is encrypted
60
+ DATA_DESCRIPTOR: 0x0008, // Sizes in data descriptor after file data
61
+ UTF8_FILENAME: 0x0800, // Filename is UTF-8 encoded
62
+ };
63
+
64
+ const ZIP_METHODS = {
65
+ STORE: 0, // No compression
66
+ DEFLATE: 8, // Deflate compression
67
+ };
68
+
69
+ // Marker value indicating ZIP64 format is required
70
+ const ZIP64_MARKER = 0xffffffff;
71
+
72
+ // Maximum size of EOCD record (22 bytes + max 65535 comment)
73
+ const MAX_EOCD_SEARCH = 65557;
74
+
75
+ // HTTP request timeout in milliseconds
76
+ const REQUEST_TIMEOUT_MS = 30000;
77
+
78
+ // ---------------------------------------------------------------------
79
+ // 2. CLI Argument Parser
80
+ // ---------------------------------------------------------------------
81
+ const CLI_OPTIONS = {
82
+ help: false,
83
+ list: false,
84
+ output: ".w3wallets",
85
+ force: false,
86
+ debug: false,
87
+ targets: [], // aliases or extension IDs
88
+ };
89
+
90
+ function printHelp() {
91
+ console.log(`
92
+ w3wallets - Download Chrome extensions from the Chrome Web Store
93
+
94
+ USAGE:
95
+ npx w3wallets [OPTIONS] <targets...>
96
+
97
+ TARGETS:
98
+ Alias name Known wallet alias (e.g., metamask, backpack)
99
+ Short alias Short form (e.g., mm, bp, pjs)
100
+ Extension ID 32-character Chrome extension ID
101
+ URL Chrome Web Store URL
102
+
103
+ OPTIONS:
104
+ -h, --help Show this help message
105
+ -l, --list List available wallet aliases
106
+ -o, --output Output directory (default: .w3wallets)
107
+ -f, --force Force re-download even if already exists
108
+ --debug Save raw .crx file for debugging
109
+
110
+ EXAMPLES:
111
+ npx w3wallets metamask # Download MetaMask
112
+ npx w3wallets mm bp # Download using short aliases
113
+ npx w3wallets --list # List available aliases
114
+ npx w3wallets -o ./extensions metamask # Custom output directory
115
+ npx w3wallets --force mm # Force re-download
116
+ npx w3wallets nkbihfbeogaeaoehlefnkodbefgpgknn # Download by extension ID
117
+ npx w3wallets "https://chromewebstore.google.com/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn"
118
+ `);
37
119
  }
38
120
 
39
- for (const alias of inputAliases) {
40
- if (!ALIASES[alias]) {
41
- console.error(
42
- `Unknown alias "${alias}". Must be one of: ${Object.keys(ALIASES).join(", ")}`,
43
- );
44
- process.exit(1);
121
+ function printList() {
122
+ console.log("\nAvailable wallet aliases:\n");
123
+ console.log(" ALIAS SHORT EXTENSION ID");
124
+ console.log(" " + "-".repeat(50));
125
+ for (const { name, short, id } of CANONICAL_ALIASES) {
126
+ console.log(` ${name.padEnd(12)} ${short.padEnd(7)} ${id}`);
45
127
  }
128
+ console.log(
129
+ "\nYou can also download any extension by ID or Chrome Web Store URL.\n",
130
+ );
131
+ }
132
+
133
+ /**
134
+ * Parse extension ID from various input formats:
135
+ * - Known alias (case-insensitive): "metamask", "MetaMask", "MM"
136
+ * - Direct extension ID: "nkbihfbeogaeaoehlefnkodbefgpgknn"
137
+ * - Chrome Web Store URL: "https://chromewebstore.google.com/detail/metamask/nkbihfbeogaeaoehlefnkodbefgpgknn"
138
+ */
139
+ function parseExtensionTarget(input) {
140
+ // Check for known alias (case-insensitive)
141
+ const normalizedInput = input.toLowerCase();
142
+ if (EXTENSION_REGISTRY[normalizedInput]) {
143
+ const id = EXTENSION_REGISTRY[normalizedInput];
144
+ // Find canonical alias name for directory
145
+ const alias = CANONICAL_ALIASES.find((a) => a.id === id);
146
+ return {
147
+ id,
148
+ name: EXTENSION_NAMES[id] || normalizedInput,
149
+ dirName: alias ? alias.name : id,
150
+ };
151
+ }
152
+
153
+ // Check if it's a Chrome Web Store URL
154
+ const urlPatterns = [
155
+ /chromewebstore\.google\.com\/detail\/[^/]+\/([a-z]{32})/i,
156
+ /chrome\.google\.com\/webstore\/detail\/[^/]+\/([a-z]{32})/i,
157
+ ];
158
+ for (const pattern of urlPatterns) {
159
+ const match = input.match(pattern);
160
+ if (match) {
161
+ const id = match[1].toLowerCase();
162
+ const alias = CANONICAL_ALIASES.find((a) => a.id === id);
163
+ return {
164
+ id,
165
+ name: EXTENSION_NAMES[id] || id,
166
+ dirName: alias ? alias.name : id,
167
+ };
168
+ }
169
+ }
170
+
171
+ // Check if it's a direct extension ID (32 lowercase letters)
172
+ if (/^[a-z]{32}$/i.test(input)) {
173
+ const id = input.toLowerCase();
174
+ const alias = CANONICAL_ALIASES.find((a) => a.id === id);
175
+ return {
176
+ id,
177
+ name: EXTENSION_NAMES[id] || id,
178
+ dirName: alias ? alias.name : id,
179
+ };
180
+ }
181
+
182
+ return null;
183
+ }
184
+
185
+ function parseArgs(args) {
186
+ let i = 0;
187
+ while (i < args.length) {
188
+ const arg = args[i];
189
+
190
+ if (arg === "-h" || arg === "--help") {
191
+ CLI_OPTIONS.help = true;
192
+ } else if (arg === "-l" || arg === "--list") {
193
+ CLI_OPTIONS.list = true;
194
+ } else if (arg === "-o" || arg === "--output") {
195
+ i++;
196
+ if (i >= args.length) {
197
+ console.error("Error: --output requires a directory path");
198
+ process.exit(1);
199
+ }
200
+ CLI_OPTIONS.output = args[i];
201
+ } else if (arg === "-f" || arg === "--force") {
202
+ CLI_OPTIONS.force = true;
203
+ } else if (arg === "--debug") {
204
+ CLI_OPTIONS.debug = true;
205
+ } else if (arg.startsWith("-")) {
206
+ console.error(`Error: Unknown option "${arg}"`);
207
+ console.error("Use --help for usage information");
208
+ process.exit(1);
209
+ } else {
210
+ // It's a target (alias, ID, or URL)
211
+ const parsed = parseExtensionTarget(arg);
212
+ if (!parsed) {
213
+ console.error(
214
+ `Error: "${arg}" is not a valid alias, extension ID, or URL`,
215
+ );
216
+ console.error(
217
+ "Use --list to see available aliases, or provide a 32-character extension ID",
218
+ );
219
+ process.exit(1);
220
+ }
221
+ CLI_OPTIONS.targets.push(parsed);
222
+ }
223
+ i++;
224
+ }
225
+ }
226
+
227
+ // Parse command line arguments
228
+ parseArgs(process.argv.slice(2));
229
+
230
+ // Handle --help
231
+ if (CLI_OPTIONS.help) {
232
+ printHelp();
233
+ process.exit(0);
234
+ }
235
+
236
+ // Handle --list
237
+ if (CLI_OPTIONS.list) {
238
+ printList();
239
+ process.exit(0);
240
+ }
241
+
242
+ // Validate we have targets
243
+ if (CLI_OPTIONS.targets.length === 0) {
244
+ console.error("Error: No extension targets specified");
245
+ console.error(
246
+ "Use --help for usage information or --list to see available aliases",
247
+ );
248
+ process.exit(1);
46
249
  }
47
250
 
48
251
  // ---------------------------------------------------------------------
49
- // 3. Main: download and extract each requested alias
252
+ // 3. Main: download and extract each requested extension
50
253
  // ---------------------------------------------------------------------
51
254
  (async function main() {
52
- for (const alias of inputAliases) {
53
- const extensionId = ALIASES[alias];
54
- console.log(`\n=== Processing alias: "${alias}" (ID: ${extensionId}) ===`);
255
+ for (const target of CLI_OPTIONS.targets) {
256
+ const { id, name, dirName } = target;
257
+ const outDir = path.join(CLI_OPTIONS.output, dirName);
55
258
 
56
- try {
57
- // 1) Download CRX
58
- const crxBuffer = await downloadCrx(extensionId);
59
- console.log(`Got CRX data for "${alias}"! ${crxBuffer.length} bytes`);
259
+ console.log(`\n=== ${name} (${id}) ===`);
60
260
 
61
- // 2) Save raw CRX to disk
62
- const outDir = path.join(".w3wallets", alias);
63
- fs.mkdirSync(outDir, { recursive: true });
261
+ // Check if already exists (skip unless --force)
262
+ const manifestPath = path.join(outDir, "manifest.json");
263
+ if (!CLI_OPTIONS.force && fs.existsSync(manifestPath)) {
264
+ console.log(`Already exists: ${outDir}`);
265
+ console.log("Use --force to re-download");
266
+ continue;
267
+ }
64
268
 
65
- const debugPath = path.join(outDir, `debug-${alias}.crx`);
66
- fs.writeFileSync(debugPath, crxBuffer);
67
- console.log(`Saved ${debugPath}`);
269
+ try {
270
+ // 1) Download CRX with progress
271
+ console.log("Downloading...");
272
+ const crxBuffer = await downloadCrx(id);
273
+ console.log(`Downloaded ${formatBytes(crxBuffer.length)}`);
274
+
275
+ // 2) Optionally save raw CRX for debugging
276
+ if (CLI_OPTIONS.debug) {
277
+ fs.mkdirSync(outDir, { recursive: true });
278
+ const debugPath = path.join(outDir, `debug-${dirName}.crx`);
279
+ fs.writeFileSync(debugPath, crxBuffer);
280
+ console.log(`Debug CRX saved: ${debugPath}`);
281
+ }
68
282
 
69
- // 3) Extract CRX into "wallets/<alias>"
283
+ // 3) Extract CRX
284
+ console.log("Extracting...");
70
285
  extractCrxToFolder(crxBuffer, outDir);
71
- console.log(`Extraction complete! See folder: ${outDir}`);
286
+ console.log(`Done: ${outDir}`);
72
287
  } catch (err) {
73
- console.error(`Failed to process "${alias}":`, err.message);
288
+ console.error(`Failed: ${err.message}`);
74
289
  process.exit(1);
75
290
  }
76
291
  }
292
+
293
+ console.log("\nAll extensions downloaded successfully!");
77
294
  })();
78
295
 
296
+ // ---------------------------------------------------------------------
297
+ // Utility: format bytes for human display
298
+ // ---------------------------------------------------------------------
299
+ function formatBytes(bytes) {
300
+ if (bytes < 1024) return `${bytes} B`;
301
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
302
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
303
+ }
304
+
79
305
  // ---------------------------------------------------------------------
80
306
  // downloadCrx: Build CRX URL and fetch it
81
307
  // ---------------------------------------------------------------------
@@ -95,7 +321,7 @@ async function downloadCrx(extensionId) {
95
321
  }
96
322
 
97
323
  // ---------------------------------------------------------------------
98
- // fetchUrl: minimal GET + redirect handling
324
+ // fetchUrl: minimal GET + redirect handling with timeout and progress
99
325
  // ---------------------------------------------------------------------
100
326
  function fetchUrl(
101
327
  targetUrl,
@@ -108,12 +334,13 @@ function fetchUrl(
108
334
  return reject(new Error("Too many redirects"));
109
335
  }
110
336
 
111
- const req = https.get(targetUrl, options, (res) => {
337
+ const reqOptions = { ...options, timeout: REQUEST_TIMEOUT_MS };
338
+ const req = https.get(targetUrl, reqOptions, (res) => {
112
339
  const { statusCode, headers } = res;
113
340
 
114
341
  // Follow redirects
115
342
  if ([301, 302, 303, 307, 308].includes(statusCode) && headers.location) {
116
- const newUrl = url.resolve(targetUrl, headers.location);
343
+ const newUrl = new URL(headers.location, targetUrl).href;
117
344
  res.resume(); // discard body
118
345
  return resolve(
119
346
  fetchUrl(newUrl, options, redirectCount + 1, maxRedirects),
@@ -127,15 +354,60 @@ function fetchUrl(
127
354
  );
128
355
  }
129
356
 
357
+ const contentLength = parseInt(headers["content-length"], 10) || 0;
130
358
  const dataChunks = [];
131
- res.on("data", (chunk) => dataChunks.push(chunk));
132
- res.on("end", () => resolve(Buffer.concat(dataChunks)));
359
+ let downloadedBytes = 0;
360
+ let lastProgressUpdate = 0;
361
+
362
+ res.on("data", (chunk) => {
363
+ dataChunks.push(chunk);
364
+ downloadedBytes += chunk.length;
365
+
366
+ // Update progress at most every 100ms to avoid flickering
367
+ const now = Date.now();
368
+ if (contentLength > 0 && now - lastProgressUpdate > 100) {
369
+ lastProgressUpdate = now;
370
+ const percent = Math.round((downloadedBytes / contentLength) * 100);
371
+ const progressBar = createProgressBar(percent);
372
+ process.stdout.write(
373
+ `\r ${progressBar} ${percent}% (${formatBytes(downloadedBytes)})`,
374
+ );
375
+ }
376
+ });
377
+
378
+ res.on("end", () => {
379
+ // Clear the progress line
380
+ if (contentLength > 0) {
381
+ process.stdout.write("\r" + " ".repeat(60) + "\r");
382
+ }
383
+ resolve(Buffer.concat(dataChunks));
384
+ });
133
385
  });
134
386
 
135
- req.on("error", (err) => reject(err));
387
+ req.on("timeout", () => {
388
+ req.destroy();
389
+ reject(
390
+ new Error(
391
+ `Request timed out after ${REQUEST_TIMEOUT_MS}ms: ${targetUrl}`,
392
+ ),
393
+ );
394
+ });
395
+
396
+ req.on("error", (err) => {
397
+ reject(new Error(`Failed to fetch ${targetUrl}: ${err.message}`));
398
+ });
136
399
  });
137
400
  }
138
401
 
402
+ // ---------------------------------------------------------------------
403
+ // createProgressBar: Generate ASCII progress bar
404
+ // ---------------------------------------------------------------------
405
+ function createProgressBar(percent, width = 20) {
406
+ const filled = Math.round((percent / 100) * width);
407
+ const empty = width - filled;
408
+ return "[" + "=".repeat(filled) + " ".repeat(empty) + "]";
409
+ }
410
+
139
411
  // ---------------------------------------------------------------------
140
412
  // extractCrxToFolder
141
413
  // 1) Checks "Cr24" magic
@@ -149,6 +421,7 @@ function extractCrxToFolder(crxBuffer, outFolder) {
149
421
 
150
422
  const version = crxBuffer.readUInt32LE(4);
151
423
  let zipStartOffset = 0;
424
+
152
425
  if (version === 2) {
153
426
  const pkLen = crxBuffer.readUInt32LE(8);
154
427
  const sigLen = crxBuffer.readUInt32LE(12);
@@ -174,16 +447,16 @@ function extractCrxToFolder(crxBuffer, outFolder) {
174
447
 
175
448
  // ---------------------------------------------------------------------
176
449
  // parseZipCentralDirectory(buffer, outFolder)
177
- // 1) Finds End of Central Directory (EOCD) record (0x06054b50).
450
+ // 1) Finds End of Central Directory (EOCD) record
178
451
  // 2) Reads central directory for file metadata
179
452
  // 3) For each file, decompress into outFolder
180
453
  // ---------------------------------------------------------------------
181
454
  function parseZipCentralDirectory(zipBuffer, outFolder) {
182
- const eocdSig = 0x06054b50;
455
+ // Find EOCD by scanning backwards from end of file
183
456
  let eocdPos = -1;
184
- const minPos = Math.max(0, zipBuffer.length - 65557);
457
+ const minPos = Math.max(0, zipBuffer.length - MAX_EOCD_SEARCH);
185
458
  for (let i = zipBuffer.length - 4; i >= minPos; i--) {
186
- if (zipBuffer.readUInt32LE(i) === eocdSig) {
459
+ if (zipBuffer.readUInt32LE(i) === ZIP_SIGNATURES.EOCD) {
187
460
  eocdPos = i;
188
461
  break;
189
462
  }
@@ -196,6 +469,11 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
196
469
  const cdSize = zipBuffer.readUInt32LE(eocdPos + 12);
197
470
  const cdOffset = zipBuffer.readUInt32LE(eocdPos + 16);
198
471
 
472
+ // ZIP64 check: marker values indicate ZIP64 format is required
473
+ if (cdOffset === ZIP64_MARKER || cdSize === ZIP64_MARKER) {
474
+ throw new Error("ZIP64 format is not supported.");
475
+ }
476
+
199
477
  if (cdOffset + cdSize > zipBuffer.length) {
200
478
  throw new Error("Central directory offset/size out of range.");
201
479
  }
@@ -204,23 +482,20 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
204
482
  const files = [];
205
483
  for (let i = 0; i < totalCD; i++) {
206
484
  const sig = zipBuffer.readUInt32LE(ptr);
207
- if (sig !== 0x02014b50) {
485
+ if (sig !== ZIP_SIGNATURES.CENTRAL_DIR) {
208
486
  throw new Error(`Central directory signature mismatch at ${ptr}`);
209
487
  }
210
488
  ptr += 4;
211
489
 
212
- /* const verMade = */ zipBuffer.readUInt16LE(ptr);
213
- ptr += 2;
490
+ ptr += 2; // version made by (unused)
214
491
  const verNeed = zipBuffer.readUInt16LE(ptr);
215
492
  ptr += 2;
216
493
  const flags = zipBuffer.readUInt16LE(ptr);
217
494
  ptr += 2;
218
495
  const method = zipBuffer.readUInt16LE(ptr);
219
496
  ptr += 2;
220
- /* const modTime = */ zipBuffer.readUInt16LE(ptr);
221
- ptr += 2;
222
- /* const modDate = */ zipBuffer.readUInt16LE(ptr);
223
- ptr += 2;
497
+ ptr += 2; // mod time (unused)
498
+ ptr += 2; // mod date (unused)
224
499
  const crc32 = zipBuffer.readUInt32LE(ptr);
225
500
  ptr += 4;
226
501
  const compSize = zipBuffer.readUInt32LE(ptr);
@@ -233,18 +508,31 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
233
508
  ptr += 2;
234
509
  const cLen = zipBuffer.readUInt16LE(ptr);
235
510
  ptr += 2;
236
- /* const diskNo = */ zipBuffer.readUInt16LE(ptr);
237
- ptr += 2;
238
- /* const intAttr = */ zipBuffer.readUInt16LE(ptr);
239
- ptr += 2;
240
- /* const extAttr = */ zipBuffer.readUInt32LE(ptr);
241
- ptr += 4;
511
+ ptr += 2; // disk number (unused)
512
+ ptr += 2; // internal attributes (unused)
513
+ ptr += 4; // external attributes (unused)
242
514
  const localHeaderOffset = zipBuffer.readUInt32LE(ptr);
243
515
  ptr += 4;
244
516
 
245
517
  const filename = zipBuffer.toString("utf8", ptr, ptr + fLen);
246
518
  ptr += fLen + xLen + cLen; // skip the extra + comment
247
519
 
520
+ // Validate: encrypted files not supported
521
+ if (flags & ZIP_FLAGS.ENCRYPTED) {
522
+ throw new Error(`Encrypted files are not supported: ${filename}`);
523
+ }
524
+
525
+ // Validate: ZIP64 extended sizes not supported
526
+ if (
527
+ compSize === ZIP64_MARKER ||
528
+ unCompSize === ZIP64_MARKER ||
529
+ localHeaderOffset === ZIP64_MARKER
530
+ ) {
531
+ throw new Error(
532
+ `ZIP64 extended information not supported for file: ${filename}`,
533
+ );
534
+ }
535
+
248
536
  files.push({
249
537
  filename,
250
538
  method,
@@ -257,19 +545,31 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
257
545
  });
258
546
  }
259
547
 
260
- fs.mkdirSync(outFolder, { recursive: true });
548
+ const resolvedOutFolder = path.resolve(outFolder);
549
+ fs.mkdirSync(resolvedOutFolder, { recursive: true });
261
550
 
262
551
  for (const file of files) {
263
552
  const { filename, method, compSize, localHeaderOffset } = file;
264
553
 
554
+ // Security: validate path to prevent directory traversal attacks
555
+ const outPath = path.join(resolvedOutFolder, filename);
556
+ if (
557
+ !outPath.startsWith(resolvedOutFolder + path.sep) &&
558
+ outPath !== resolvedOutFolder
559
+ ) {
560
+ throw new Error(
561
+ `Path traversal detected, refusing to extract: ${filename}`,
562
+ );
563
+ }
564
+
265
565
  if (filename.endsWith("/")) {
266
- fs.mkdirSync(path.join(outFolder, filename), { recursive: true });
566
+ fs.mkdirSync(outPath, { recursive: true });
267
567
  continue;
268
568
  }
269
569
 
270
570
  let lhPtr = localHeaderOffset;
271
571
  const localSig = zipBuffer.readUInt32LE(lhPtr);
272
- if (localSig !== 0x04034b50) {
572
+ if (localSig !== ZIP_SIGNATURES.LOCAL_FILE) {
273
573
  throw new Error(`Local file header mismatch at ${lhPtr} for ${filename}`);
274
574
  }
275
575
  lhPtr += 4;
@@ -290,12 +590,11 @@ function parseZipCentralDirectory(zipBuffer, outFolder) {
290
590
  lhPtr += lhFNameLen + lhXLen;
291
591
  const fileData = zipBuffer.slice(lhPtr, lhPtr + compSize);
292
592
 
293
- const outPath = path.join(outFolder, filename);
294
593
  fs.mkdirSync(path.dirname(outPath), { recursive: true });
295
594
 
296
- if (method === 0) {
595
+ if (method === ZIP_METHODS.STORE) {
297
596
  fs.writeFileSync(outPath, fileData);
298
- } else if (method === 8) {
597
+ } else if (method === ZIP_METHODS.DEFLATE) {
299
598
  const unzipped = zlib.inflateRawSync(fileData);
300
599
  fs.writeFileSync(outPath, unzipped);
301
600
  } else {