@nissyai/desktop 0.2.19 → 0.2.25

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/bin/nissy.mjs CHANGED
@@ -9,16 +9,18 @@
9
9
  import { execFileSync, spawn } from "node:child_process";
10
10
  import {
11
11
  existsSync,
12
+ lstatSync,
12
13
  mkdirSync,
13
14
  readdirSync,
14
15
  readFileSync,
16
+ realpathSync,
15
17
  renameSync,
16
18
  rmSync,
17
19
  writeFileSync,
18
20
  } from "node:fs";
19
21
  import { createRequire } from "node:module";
20
22
  import { homedir } from "node:os";
21
- import { dirname, join } from "node:path";
23
+ import { dirname, isAbsolute, join, relative } from "node:path";
22
24
  import { fileURLToPath } from "node:url";
23
25
 
24
26
  import {
@@ -28,6 +30,7 @@ import {
28
30
  npxChannelEnv,
29
31
  resolveLaunchCommand,
30
32
  resolvePayloadSpec,
33
+ unsafeArchiveEntries,
31
34
  UnsupportedPlatformError,
32
35
  } from "../src/resolve.mjs";
33
36
  import { DISTRIBUTION } from "../src/distribution.mjs";
@@ -39,6 +42,59 @@ function fail(message) {
39
42
  process.exit(1);
40
43
  }
41
44
 
45
+ function validateArchive(archivePath, bundleName) {
46
+ const listing = execFileSync("tar", ["-tzf", archivePath], {
47
+ encoding: "utf8",
48
+ stdio: ["ignore", "pipe", "inherit"],
49
+ });
50
+ const entries = listing
51
+ .split(/\r?\n/u)
52
+ .map((entry) => entry.trim())
53
+ .filter((entry) => entry.length > 0);
54
+ const unsafe = unsafeArchiveEntries(entries, bundleName);
55
+ if (unsafe.length > 0) {
56
+ throw new Error(
57
+ `payload archive contains unsafe entries: ${unsafe.slice(0, 5).join(", ")}`,
58
+ );
59
+ }
60
+ }
61
+
62
+ function isPathInside(root, candidate) {
63
+ const escaped = relative(root, candidate);
64
+ return escaped !== "" && !escaped.startsWith("..") && !isAbsolute(escaped);
65
+ }
66
+
67
+ function isExtractedBundleInside(tempDir, bundlePath) {
68
+ try {
69
+ const tempRoot = realpathSync(tempDir);
70
+ const bundleStat = lstatSync(bundlePath);
71
+ if (bundleStat.isSymbolicLink() || !bundleStat.isDirectory()) return false;
72
+ const bundleRoot = realpathSync(bundlePath);
73
+ if (!isPathInside(tempRoot, bundleRoot)) return false;
74
+
75
+ // The top-level bundle can be inside the temp directory while an inner
76
+ // symlink still points outside it. Walk without following directories and
77
+ // reject every such link before the bundle is moved into Applications.
78
+ const pending = [bundlePath];
79
+ while (pending.length > 0) {
80
+ const current = pending.pop();
81
+ if (current === undefined) continue;
82
+ const stat = lstatSync(current);
83
+ if (stat.isSymbolicLink()) {
84
+ if (!isPathInside(tempRoot, realpathSync(current))) return false;
85
+ continue;
86
+ }
87
+ if (!stat.isDirectory()) continue;
88
+ for (const entry of readdirSync(current)) {
89
+ pending.push(join(current, entry));
90
+ }
91
+ }
92
+ return true;
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
42
98
  let spec;
43
99
  try {
44
100
  spec = resolvePayloadSpec(process.platform, process.arch);
@@ -99,11 +155,12 @@ if (existsSync(join(payloadRoot, "app"))) {
99
155
  try {
100
156
  rmSync(tmp, { recursive: true, force: true });
101
157
  mkdirSync(tmp, { recursive: true });
158
+ validateArchive(archivePath, bundleName);
102
159
  execFileSync("tar", ["-xzf", archivePath, "-C", tmp], { stdio: "inherit" });
103
160
  const tmpApp = join(tmp, bundleName);
104
- if (!existsSync(tmpApp)) {
161
+ if (!existsSync(tmpApp) || !isExtractedBundleInside(tmp, tmpApp)) {
105
162
  rmSync(tmp, { recursive: true, force: true });
106
- fail(`payload archive ${archivePath} did not contain ${bundleName}`);
163
+ fail(`payload archive ${archivePath} did not contain a safe ${bundleName}`);
107
164
  }
108
165
  mkdirSync(appsDir, { recursive: true });
109
166
  // Swap: move any existing install aside, rename the new one into place,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nissyai/desktop",
3
- "version": "0.2.19",
3
+ "version": "0.2.25",
4
4
  "type": "module",
5
5
  "description": "Launcher for the Nissy desktop app. Resolves the host's app payload and spawns it. Run via `npx @nissyai/desktop`; a global install (`npm i -g @nissyai/desktop`) exposes the `nissy` command.",
6
6
  "license": "SEE LICENSE IN LICENSE",
@@ -20,8 +20,8 @@
20
20
  "src/"
21
21
  ],
22
22
  "optionalDependencies": {
23
- "@nissyai/app-win32-x64": "0.2.19",
24
- "@nissyai/app-darwin-arm64": "0.2.19",
25
- "@nissyai/app-darwin-x64": "0.2.19"
23
+ "@nissyai/app-win32-x64": "0.2.25",
24
+ "@nissyai/app-darwin-arm64": "0.2.25",
25
+ "@nissyai/app-darwin-x64": "0.2.25"
26
26
  }
27
27
  }
package/src/resolve.mjs CHANGED
@@ -112,6 +112,37 @@ export function payloadCacheKey(pkg, version) {
112
112
  return `${safe}@${version}`;
113
113
  }
114
114
 
115
+ /**
116
+ * Return archive entry names that must be rejected before extracting a macOS
117
+ * payload. The archive is produced by our build, but it is still untrusted at
118
+ * this boundary: npm package replacement or a compromised publish could turn
119
+ * a path traversal entry into a write outside the temporary install directory.
120
+ *
121
+ * Tar listings commonly prefix names with `./`, and the bundle itself contains
122
+ * legitimate framework symlinks, so this validates names only. The caller
123
+ * separately verifies the extracted top-level bundle is a real path inside the
124
+ * temporary directory before swapping it into ~/Applications.
125
+ *
126
+ * @param {readonly string[]} entries names from `tar -tzf`
127
+ * @param {string} bundleName expected top-level `.app` directory name
128
+ * @returns {string[]} original unsafe entry names
129
+ */
130
+ export function unsafeArchiveEntries(entries, bundleName) {
131
+ const root = bundleName.replace(/\\/g, "/").replace(/^\.\/+|\/+$/g, "");
132
+ return entries.filter((entry) => {
133
+ const normalized = entry.replace(/\\/g, "/").replace(/^\.\/+/, "");
134
+ if (normalized === "") return false;
135
+ if (
136
+ normalized.startsWith("/") ||
137
+ /^[A-Za-z]:\//u.test(normalized) ||
138
+ normalized.split("/").includes("..")
139
+ ) {
140
+ return true;
141
+ }
142
+ return normalized !== root && !normalized.startsWith(`${root}/`);
143
+ });
144
+ }
145
+
115
146
  /**
116
147
  * Given the entry names in the cache root and the current cache key, return the
117
148
  * stale extracted-payload dirs to prune (older versions of the SAME payload).