Skip to content

Commit b581d5f

Browse files
authored
fix(security): harden clerk init dependency-install spawn (#302)
* fix(security): harden clerk init dependency-install spawn `clerk init` spawns the project's package manager in attacker-controlled cwd to install the framework SDK. On pnpm this autoloaded `.pnpmfile.cjs` from cwd at install startup, executing arbitrary JS via `require()` before any package resolved. Every PM additionally runs lifecycle scripts (`preinstall`/`install`/`postinstall`) from the project's `package.json`. Pass `--ignore-pnpmfile` (pnpm) and `--ignore-scripts` (all PMs) to the install spawn so a cloned-then-`clerk init`'d attacker repo can't gain arbitrary code-exec at install time. Decouple `globalInstallCommand` from `pmInstallCommand` — the upgrade-Clerk hint message is a copy-pasteable instruction to the user and must not inherit the hardening flags (lifecycle scripts are how some PMs link the binary into PATH). Fixes AIE-969. * refactor(init): apply self-review feedback to security hardening - Extract PM_INSTALL_HARDENING_FLAGS as single source of truth so the `clerk init --starter` install path in bootstrap-registry.ts shares the flags with the SDK install in heuristics.ts. Without this, the two PM_INSTALL_COMMANDS tables drift independently and the `--starter` path loses the hardening. - Fix `satisfies` constraint on installer.ts GLOBAL_UPDATE_COMMANDS to bind against Installer (not PackageManager) — those types are independently maintained, and the lookup is called with an Installer. - Replace per-PM for-loops in the test with `test.each(PACKAGE_MANAGERS)` so each PM becomes its own named test case. - Drop the AIE-969 ticket reference from the test comment (ticket refs belong in PR description, not source).
1 parent da2e3cf commit b581d5f

5 files changed

Lines changed: 76 additions & 14 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"clerk": patch
3+
---
4+
5+
Harden the dependency-install step of `clerk init`. Previously, the package-manager spawn in attacker-controlled cwd could execute arbitrary JavaScript via pnpm's `.pnpmfile.cjs` autoload or via lifecycle scripts (`preinstall`/`install`/`postinstall`) in the project's `package.json`. The install command now passes `--ignore-pnpmfile` (pnpm) and `--ignore-scripts` (all package managers).

packages/cli-core/src/commands/init/bootstrap-registry.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PackageManager } from "../../lib/package-manager.ts";
1+
import { PM_INSTALL_HARDENING_FLAGS, type PackageManager } from "../../lib/package-manager.ts";
22

33
export type BootstrapEntry = {
44
label: string;
@@ -147,9 +147,15 @@ export const BOOTSTRAP_REGISTRY: BootstrapEntry[] = [
147147
...BOOTSTRAP_AUTHENTICATED_REGISTRY,
148148
];
149149

150+
// Hardening flags come from PM_INSTALL_HARDENING_FLAGS (see package-manager.ts
151+
// for the threat model). `clerk init --starter` runs this in projectDir,
152+
// which is freshly scaffolded from an official template but lives inside the
153+
// user's cwd — defense-in-depth covers a hypothetical attacker-planted
154+
// `.pnpmfile.cjs` or lifecycle script in the template, and keeps both install
155+
// surfaces (this one and the SDK install in heuristics.ts) symmetric.
150156
export const PM_INSTALL_COMMANDS: Record<PackageManager, string[]> = {
151-
npm: ["npm", "install"],
152-
yarn: ["yarn", "install"],
153-
pnpm: ["pnpm", "install"],
154-
bun: ["bun", "install"],
157+
npm: ["npm", "install", ...PM_INSTALL_HARDENING_FLAGS.npm],
158+
yarn: ["yarn", "install", ...PM_INSTALL_HARDENING_FLAGS.yarn],
159+
pnpm: ["pnpm", "install", ...PM_INSTALL_HARDENING_FLAGS.pnpm],
160+
bun: ["bun", "install", ...PM_INSTALL_HARDENING_FLAGS.bun],
155161
};

packages/cli-core/src/lib/installer.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { access, realpath, stat } from "node:fs/promises";
1212
import { homedir } from "node:os";
1313
import { delimiter, join, sep } from "node:path";
1414
import { UPDATE_PACKAGE_NAME } from "./constants.ts";
15-
import { pmInstallCommand, type PackageManager } from "./package-manager.ts";
1615

1716
// ── Types ────────────────────────────────────────────────────────────────────
1817

@@ -353,12 +352,21 @@ function normalizeWindowsPath(p: string): string {
353352

354353
// ── Install command strings ─────────────────────────────────────────────────
355354

355+
// Human-facing upgrade-Clerk commands. Intentionally NOT built from
356+
// `pmInstallCommand`: that helper adds `--ignore-pnpmfile` / `--ignore-scripts`
357+
// to defend `clerk init`'s spawn in attacker-controlled cwd. Those flags
358+
// don't belong in an upgrade hint a user copy-pastes to install the trusted
359+
// `clerk` package globally — postinstall scripts are how some PMs link the
360+
// binary into PATH.
361+
const GLOBAL_UPDATE_COMMANDS = {
362+
bun: "bun add -g",
363+
pnpm: "pnpm add -g",
364+
npm: "npm install -g",
365+
} satisfies Record<Exclude<Installer, "homebrew" | "yarn">, string>;
366+
356367
/** Human-readable install/update command for the given installer. */
357368
export function globalInstallCommand(installer: Installer, packageSpec: string): string {
358369
if (installer === "homebrew") return `brew upgrade ${UPDATE_PACKAGE_NAME}`;
359370
if (installer === "yarn") return `yarn global add ${packageSpec}`;
360-
361-
// For bun, pnpm, and npm the global form is the local `<pm> add` command
362-
// with a `-g` flag appended — reuse `pmInstallCommand` as the base.
363-
return `${pmInstallCommand(installer as PackageManager)} -g ${packageSpec}`;
371+
return `${GLOBAL_UPDATE_COMMANDS[installer]} ${packageSpec}`;
364372
}
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { test, expect, describe } from "bun:test";
2+
import { pmInstallCommand, PACKAGE_MANAGERS } from "./package-manager.ts";
3+
4+
describe("pmInstallCommand hardening flags", () => {
5+
// `clerk init` spawns the project package manager in attacker-controlled
6+
// cwd. These flags are load-bearing — `--ignore-pnpmfile` blocks pnpm's
7+
// `.pnpmfile.cjs` autoload code-exec, `--ignore-scripts` blocks
8+
// lifecycle-script code-exec from the attacker's package.json. If any of
9+
// these drop, the install spawn regains its arbitrary-code-exec primitive.
10+
11+
test.each([...PACKAGE_MANAGERS])("%s emits --ignore-scripts", (pm) => {
12+
expect(pmInstallCommand(pm)).toContain("--ignore-scripts");
13+
});
14+
15+
test("pnpm additionally emits --ignore-pnpmfile", () => {
16+
expect(pmInstallCommand("pnpm")).toContain("--ignore-pnpmfile");
17+
});
18+
19+
// heuristics.runPmInstall splits addCmd by " " and uses [0] as the binary
20+
// it probes via Bun.which(). A flag-prefixed binary would break that.
21+
test.each([...PACKAGE_MANAGERS])("%s install command starts with the bare binary", (pm) => {
22+
expect(pmInstallCommand(pm).split(" ")[0]).toBe(pm);
23+
});
24+
});

packages/cli-core/src/lib/package-manager.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,30 @@ export const PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"] as const;
88

99
export type PackageManager = (typeof PACKAGE_MANAGERS)[number];
1010

11+
// Security: `clerk init` runs the user's package manager in attacker-controlled
12+
// cwd to install the framework SDK. Two cwd-reachable code-exec primitives
13+
// exist on a vanilla `<pm> add`/`<pm> install`:
14+
// 1. pnpm autoloads `.pnpmfile.cjs` at install startup (top-level code runs
15+
// via `require()` before any package resolves). `--ignore-pnpmfile`
16+
// disables that loader.
17+
// 2. Every PM runs lifecycle scripts (preinstall/install/postinstall) from
18+
// the project's package.json on every install. `--ignore-scripts` skips
19+
// them; the legitimate user re-runs install later for native modules.
20+
//
21+
// Single source of truth — `PM_INSTALL_COMMANDS` below and the `--starter`
22+
// bootstrap install in `commands/init/bootstrap-registry.ts` both consume it.
23+
export const PM_INSTALL_HARDENING_FLAGS = {
24+
bun: ["--ignore-scripts"],
25+
yarn: ["--ignore-scripts"],
26+
pnpm: ["--ignore-pnpmfile", "--ignore-scripts"],
27+
npm: ["--ignore-scripts"],
28+
} as const satisfies Record<PackageManager, readonly string[]>;
29+
1130
const PM_INSTALL_COMMANDS = {
12-
bun: "bun add",
13-
yarn: "yarn add",
14-
pnpm: "pnpm add",
15-
npm: "npm install",
31+
bun: ["bun", "add", ...PM_INSTALL_HARDENING_FLAGS.bun].join(" "),
32+
yarn: ["yarn", "add", ...PM_INSTALL_HARDENING_FLAGS.yarn].join(" "),
33+
pnpm: ["pnpm", "add", ...PM_INSTALL_HARDENING_FLAGS.pnpm].join(" "),
34+
npm: ["npm", "install", ...PM_INSTALL_HARDENING_FLAGS.npm].join(" "),
1635
} satisfies Record<PackageManager, string>;
1736

1837
/** Returns the `<pm> add`-style command for installing dependencies. */

0 commit comments

Comments
 (0)