diff --git a/.changeset/fix-pnpmfile-autoload.md b/.changeset/fix-pnpmfile-autoload.md new file mode 100644 index 00000000..6c36d80e --- /dev/null +++ b/.changeset/fix-pnpmfile-autoload.md @@ -0,0 +1,5 @@ +--- +"clerk": patch +--- + +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). diff --git a/packages/cli-core/src/commands/init/bootstrap-registry.ts b/packages/cli-core/src/commands/init/bootstrap-registry.ts index 0b9ca792..a6041064 100644 --- a/packages/cli-core/src/commands/init/bootstrap-registry.ts +++ b/packages/cli-core/src/commands/init/bootstrap-registry.ts @@ -1,4 +1,4 @@ -import type { PackageManager } from "../../lib/package-manager.ts"; +import { PM_INSTALL_HARDENING_FLAGS, type PackageManager } from "../../lib/package-manager.ts"; export type BootstrapEntry = { label: string; @@ -147,9 +147,15 @@ export const BOOTSTRAP_REGISTRY: BootstrapEntry[] = [ ...BOOTSTRAP_AUTHENTICATED_REGISTRY, ]; +// Hardening flags come from PM_INSTALL_HARDENING_FLAGS (see package-manager.ts +// for the threat model). `clerk init --starter` runs this in projectDir, +// which is freshly scaffolded from an official template but lives inside the +// user's cwd — defense-in-depth covers a hypothetical attacker-planted +// `.pnpmfile.cjs` or lifecycle script in the template, and keeps both install +// surfaces (this one and the SDK install in heuristics.ts) symmetric. export const PM_INSTALL_COMMANDS: Record = { - npm: ["npm", "install"], - yarn: ["yarn", "install"], - pnpm: ["pnpm", "install"], - bun: ["bun", "install"], + npm: ["npm", "install", ...PM_INSTALL_HARDENING_FLAGS.npm], + yarn: ["yarn", "install", ...PM_INSTALL_HARDENING_FLAGS.yarn], + pnpm: ["pnpm", "install", ...PM_INSTALL_HARDENING_FLAGS.pnpm], + bun: ["bun", "install", ...PM_INSTALL_HARDENING_FLAGS.bun], }; diff --git a/packages/cli-core/src/lib/installer.ts b/packages/cli-core/src/lib/installer.ts index 3e04b960..3e369f27 100644 --- a/packages/cli-core/src/lib/installer.ts +++ b/packages/cli-core/src/lib/installer.ts @@ -12,7 +12,6 @@ import { access, realpath, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { delimiter, join, sep } from "node:path"; import { UPDATE_PACKAGE_NAME } from "./constants.ts"; -import { pmInstallCommand, type PackageManager } from "./package-manager.ts"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -353,12 +352,21 @@ function normalizeWindowsPath(p: string): string { // ── Install command strings ───────────────────────────────────────────────── +// Human-facing upgrade-Clerk commands. Intentionally NOT built from +// `pmInstallCommand`: that helper adds `--ignore-pnpmfile` / `--ignore-scripts` +// to defend `clerk init`'s spawn in attacker-controlled cwd. Those flags +// don't belong in an upgrade hint a user copy-pastes to install the trusted +// `clerk` package globally — postinstall scripts are how some PMs link the +// binary into PATH. +const GLOBAL_UPDATE_COMMANDS = { + bun: "bun add -g", + pnpm: "pnpm add -g", + npm: "npm install -g", +} satisfies Record, string>; + /** Human-readable install/update command for the given installer. */ export function globalInstallCommand(installer: Installer, packageSpec: string): string { if (installer === "homebrew") return `brew upgrade ${UPDATE_PACKAGE_NAME}`; if (installer === "yarn") return `yarn global add ${packageSpec}`; - - // For bun, pnpm, and npm the global form is the local ` add` command - // with a `-g` flag appended — reuse `pmInstallCommand` as the base. - return `${pmInstallCommand(installer as PackageManager)} -g ${packageSpec}`; + return `${GLOBAL_UPDATE_COMMANDS[installer]} ${packageSpec}`; } diff --git a/packages/cli-core/src/lib/package-manager.test.ts b/packages/cli-core/src/lib/package-manager.test.ts new file mode 100644 index 00000000..ee276daf --- /dev/null +++ b/packages/cli-core/src/lib/package-manager.test.ts @@ -0,0 +1,24 @@ +import { test, expect, describe } from "bun:test"; +import { pmInstallCommand, PACKAGE_MANAGERS } from "./package-manager.ts"; + +describe("pmInstallCommand hardening flags", () => { + // `clerk init` spawns the project package manager in attacker-controlled + // cwd. These flags are load-bearing — `--ignore-pnpmfile` blocks pnpm's + // `.pnpmfile.cjs` autoload code-exec, `--ignore-scripts` blocks + // lifecycle-script code-exec from the attacker's package.json. If any of + // these drop, the install spawn regains its arbitrary-code-exec primitive. + + test.each([...PACKAGE_MANAGERS])("%s emits --ignore-scripts", (pm) => { + expect(pmInstallCommand(pm)).toContain("--ignore-scripts"); + }); + + test("pnpm additionally emits --ignore-pnpmfile", () => { + expect(pmInstallCommand("pnpm")).toContain("--ignore-pnpmfile"); + }); + + // heuristics.runPmInstall splits addCmd by " " and uses [0] as the binary + // it probes via Bun.which(). A flag-prefixed binary would break that. + test.each([...PACKAGE_MANAGERS])("%s install command starts with the bare binary", (pm) => { + expect(pmInstallCommand(pm).split(" ")[0]).toBe(pm); + }); +}); diff --git a/packages/cli-core/src/lib/package-manager.ts b/packages/cli-core/src/lib/package-manager.ts index 34fb6c18..fddbe446 100644 --- a/packages/cli-core/src/lib/package-manager.ts +++ b/packages/cli-core/src/lib/package-manager.ts @@ -8,11 +8,30 @@ export const PACKAGE_MANAGERS = ["bun", "pnpm", "yarn", "npm"] as const; export type PackageManager = (typeof PACKAGE_MANAGERS)[number]; +// Security: `clerk init` runs the user's package manager in attacker-controlled +// cwd to install the framework SDK. Two cwd-reachable code-exec primitives +// exist on a vanilla ` add`/` install`: +// 1. pnpm autoloads `.pnpmfile.cjs` at install startup (top-level code runs +// via `require()` before any package resolves). `--ignore-pnpmfile` +// disables that loader. +// 2. Every PM runs lifecycle scripts (preinstall/install/postinstall) from +// the project's package.json on every install. `--ignore-scripts` skips +// them; the legitimate user re-runs install later for native modules. +// +// Single source of truth — `PM_INSTALL_COMMANDS` below and the `--starter` +// bootstrap install in `commands/init/bootstrap-registry.ts` both consume it. +export const PM_INSTALL_HARDENING_FLAGS = { + bun: ["--ignore-scripts"], + yarn: ["--ignore-scripts"], + pnpm: ["--ignore-pnpmfile", "--ignore-scripts"], + npm: ["--ignore-scripts"], +} as const satisfies Record; + const PM_INSTALL_COMMANDS = { - bun: "bun add", - yarn: "yarn add", - pnpm: "pnpm add", - npm: "npm install", + bun: ["bun", "add", ...PM_INSTALL_HARDENING_FLAGS.bun].join(" "), + yarn: ["yarn", "add", ...PM_INSTALL_HARDENING_FLAGS.yarn].join(" "), + pnpm: ["pnpm", "add", ...PM_INSTALL_HARDENING_FLAGS.pnpm].join(" "), + npm: ["npm", "install", ...PM_INSTALL_HARDENING_FLAGS.npm].join(" "), } satisfies Record; /** Returns the ` add`-style command for installing dependencies. */