iterate-skill-installer 2.6.0 → 2.7.0

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 (2) hide show
  1. package/lib/installer.js +41 -0
  2. package/package.json +1 -1
package/lib/installer.js CHANGED
@@ -194,6 +194,31 @@ function runCommand(bin, args, options = {}) {
194
194
  });
195
195
  }
196
196
 
197
+ async function rejectLinkEntries(tarballPath) {
198
+ // Inspect the verbose listing and reject any symbolic ("l") or hard ("h")
199
+ // link entry. We deliberately reject *all* links rather than validating
200
+ // their targets: the release tarball contains none, so any link is a sign
201
+ // of a tampered archive and refusing it outright is the safest response to
202
+ // a symlink-escape attempt (see extractTarball above).
203
+ const verbose = await runCommand('tar', ['-tvzf', tarballPath]);
204
+ for (const line of verbose.split(/\r?\n/)) {
205
+ if (!line) {
206
+ continue;
207
+ }
208
+ const type = line[0];
209
+ if (type === 'l') {
210
+ throw new InstallerError(
211
+ 'Refusing to extract tarball: archive contains a symbolic link, which the release never ships.',
212
+ );
213
+ }
214
+ if (type === 'h') {
215
+ throw new InstallerError(
216
+ 'Refusing to extract tarball: archive contains a hard link, which the release never ships.',
217
+ );
218
+ }
219
+ }
220
+ }
221
+
197
222
  async function extractTarball(tarballPath, destDir) {
198
223
  // Use the system tar command (available on macOS, Linux, and Windows 10+),
199
224
  // but defend against path-traversal tarballs: before extracting we list the
@@ -205,6 +230,13 @@ async function extractTarball(tarballPath, destDir) {
205
230
  // exactly one top-level directory (see README "Release tarball structure").
206
231
  // Archives that violate that contract — multiple top-level directories, or
207
232
  // entries with no path left after stripping — are rejected outright.
233
+ //
234
+ // A name-based `../` check alone is not enough: a tarball can smuggle a
235
+ // symlink whose in-archive name looks safe but whose target points outside
236
+ // destDir, then place files "through" that symlink (e.g. `link/passwd` where
237
+ // `link -> /etc`). Because extraction happens with a system `tar`, the write
238
+ // would follow the link before we could validate — so any symbolic or hard
239
+ // link is rejected *before* extraction. The release tarball never uses links.
208
240
  const absoluteDest = path.resolve(destDir);
209
241
  fs.mkdirSync(absoluteDest, { recursive: true });
210
242
 
@@ -215,6 +247,13 @@ async function extractTarball(tarballPath, destDir) {
215
247
  throw new InstallerError('Refusing to extract tarball: archive contains no entries.');
216
248
  }
217
249
 
250
+ // 1b. Reject any symbolic or hard link entry before touching the tree.
251
+ // The verbose listing's first character is the entry type for both GNU tar
252
+ // and bsdtar (macOS): '-' regular, 'd' directory, 'l' symbolic link, 'h'
253
+ // hard link. We only need to detect links, so a cheap first-char scan is
254
+ // sufficient and avoids relying on the rest of the format.
255
+ await rejectLinkEntries(tarballPath);
256
+
218
257
  // 2. Enforce the single-top-level-directory contract required by
219
258
  // --strip-components=1.
220
259
  const topLevelComponents = new Set(entries.map((entry) => entry.split('/')[0]));
@@ -659,4 +698,6 @@ module.exports = {
659
698
  resolveInstallMode,
660
699
  askYesNo,
661
700
  parseChecksums,
701
+ extractTarball,
702
+ rejectLinkEntries,
662
703
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "iterate-skill-installer",
3
- "version": "2.6.0",
3
+ "version": "2.7.0",
4
4
  "description": "One-command installer for iterate-skill across AI coding assistants",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {