@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 +60 -3
- package/package.json +4 -4
- package/src/resolve.mjs +31 -0
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.
|
|
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.
|
|
24
|
-
"@nissyai/app-darwin-arm64": "0.2.
|
|
25
|
-
"@nissyai/app-darwin-x64": "0.2.
|
|
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).
|