Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/update/npm-invocation.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function resolveNpmCommand(
const entryPath = realpath(entry);
const candidatePath = realpath(candidate);
const cwdPath = realpath(cwd);
const persistBin = win32.join(env.USERPROFILE, "scoop", "persist", win32.basename(app), "bin");
const persistBin = win32.join(realpath(env.USERPROFILE), "scoop", "persist", win32.basename(app), "bin");
const insideInstall = isInside(currentPath, entryPath);
const persistedBin = isSamePath(entry, win32.join(app, "current", "bin"))
&& isSamePath(entryPath, persistBin);
Expand Down
5 changes: 3 additions & 2 deletions structure/ops/service-and-sidecars.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,11 +382,12 @@ the refresh before it produced no serving proxy; an ownership veto still ends as

On Windows, `src/update/npm-invocation.mjs` admits only the exact
`%USERPROFILE%\scoop\apps\nodejs{,-lts}\current` and `current\bin` PATH entries
from outside that Node installation. It resolves the junction, PATH entry, npm candidate,
from outside that Node installation. It resolves the home, junction, PATH entry, npm candidate,
and cwd to physical paths; `current` must remain within its Scoop app directory and
the npm candidate within the admitted entry. `current\bin` may point to the default
`%USERPROFILE%\scoop\persist\nodejs{,-lts}\bin`; cwd inside that persistent bin
is excluded too. Unreadable paths fail closed. Other Scoop apps, version-directory
is excluded too. The fixed persist suffix is appended to the physical home, accepting 8.3 home
aliases without trusting a redirected persist subtree. Unreadable paths fail closed. Other Scoop apps, version-directory
PATH entries (`NO_JUNCTION`), custom home-root Scoop installs, arbitrary descendants,
and cwd inside the resolved Node installation are not admitted.

Expand Down
47 changes: 47 additions & 0 deletions tests/update/update-npm-invocation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,53 @@ describe("Windows npm update invocation", () => {
}, { cwd: home, exists: () => true })).toBeNull();
});

test.each(["nodejs", "nodejs-lts"])("canonicalizes only the short-home alias for Scoop %s persisted bins", appName => {
const physicalHome = "C:\\Users\\Runner Administrator";
for (const home of ["C:\\Users\\RUNNER~1", physicalHome]) {
const app = `${home}\\scoop\\apps\\${appName}`;
const physicalApp = `${physicalHome}\\scoop\\apps\\${appName}`;
const current = `${app}\\current`;
const version = `${physicalApp}\\24.1.0`;
const entry = `${current}\\bin`;
const candidate = `${entry}\\npm.cmd`;
const expectedPersist = `${home}\\scoop\\persist\\${appName}\\bin`;
const physicalPersist = `${physicalHome}\\scoop\\persist\\${appName}\\bin`;
const cwdAlias = "C:\\launch-alias";
const baseline = new Map<string, string>([
[home, physicalHome], [app, physicalApp], [current, version],
[entry, physicalPersist], [candidate, `${physicalPersist}\\npm.cmd`],
[expectedPersist, physicalPersist], [physicalPersist, physicalPersist],
[cwdAlias, physicalHome],
]);
const resolve = (overrides: Array<[string, string | undefined]> = []) => {
const paths = new Map<string, string | undefined>(baseline);
for (const [from, to] of overrides) paths.set(from, to);
return resolveNpmCommand("win32", { USERPROFILE: home, PATH: entry, PATHEXT: ".CMD" }, {
cwd: cwdAlias, exists: path => path === candidate,
realpath: path => {
const target = paths.get(path);
if (target === undefined) throw new Error("unresolved synthetic path");
return target;
},
});
};
expect(resolve()).toBe(candidate);
for (const redirected of [`${physicalHome}\\other-bin`, "D:\\another-home\\bin"]) {
// Even resolving the expected persist path reaches the attacker target:
// accepting that result would silently redefine the trusted suffix.
expect(resolve([[entry, redirected], [candidate, `${redirected}\\npm.cmd`],
[expectedPersist, redirected], [physicalPersist, redirected]])).toBeNull();
}
expect(resolve([[candidate, `${physicalHome}\\elsewhere\\npm.cmd`]])).toBeNull();
expect(resolve([[current, "D:\\outside-app"]])).toBeNull();
expect(resolve([[current, physicalApp]])).toBeNull();
expect(resolve([[cwdAlias, version]])).toBeNull();
expect(resolve([[cwdAlias, physicalPersist]])).toBeNull();
expect(resolve([[home, "D:\\different-home"]])).toBeNull();
expect(resolve([[home, undefined]])).toBeNull();
}
});

test.skipIf(process.platform !== "win32")("resolves real Scoop junctions but rejects resolved cwd and redirected targets", () => {
const home = mkdtempSync(join(tmpdir(), "ocx-scoop-node-"));
try {
Expand Down
Loading