From b9f61931d4c068017281d6daa445d8c73398dea8 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 21 May 2026 09:30:45 -0300 Subject: [PATCH 1/2] fix(security): harden clerk init dependency-install spawn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- .changeset/fix-pnpmfile-autoload.md | 5 ++++ packages/cli-core/src/lib/installer.ts | 19 ++++++++---- .../cli-core/src/lib/package-manager.test.ts | 29 +++++++++++++++++++ packages/cli-core/src/lib/package-manager.ts | 17 ++++++++--- 4 files changed, 61 insertions(+), 9 deletions(-) create mode 100644 .changeset/fix-pnpmfile-autoload.md create mode 100644 packages/cli-core/src/lib/package-manager.test.ts 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/lib/installer.ts b/packages/cli-core/src/lib/installer.ts index 3e04b960..006c58e8 100644 --- a/packages/cli-core/src/lib/installer.ts +++ b/packages/cli-core/src/lib/installer.ts @@ -12,7 +12,7 @@ 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"; +import type { PackageManager } from "./package-manager.ts"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -353,12 +353,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..8d5203f6 --- /dev/null +++ b/packages/cli-core/src/lib/package-manager.test.ts @@ -0,0 +1,29 @@ +import { test, expect, describe } from "bun:test"; +import { pmInstallCommand, PACKAGE_MANAGERS } from "./package-manager.ts"; + +describe("pmInstallCommand hardening flags", () => { + // AIE-969: `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("every package manager emits --ignore-scripts", () => { + for (const pm of PACKAGE_MANAGERS) { + expect(pmInstallCommand(pm)).toContain("--ignore-scripts"); + } + }); + + test("pnpm additionally emits --ignore-pnpmfile", () => { + expect(pmInstallCommand("pnpm")).toContain("--ignore-pnpmfile"); + }); + + test("first token is the bare binary name (spawn tokenization)", () => { + // heuristics.runPmInstall splits addCmd by " " and uses [0] as the binary + // it probes via Bun.which(). A flag-prefixed binary would break that. + expect(pmInstallCommand("bun").split(" ")[0]).toBe("bun"); + expect(pmInstallCommand("pnpm").split(" ")[0]).toBe("pnpm"); + expect(pmInstallCommand("yarn").split(" ")[0]).toBe("yarn"); + expect(pmInstallCommand("npm").split(" ")[0]).toBe("npm"); + }); +}); diff --git a/packages/cli-core/src/lib/package-manager.ts b/packages/cli-core/src/lib/package-manager.ts index 34fb6c18..2e9cf6ee 100644 --- a/packages/cli-core/src/lib/package-manager.ts +++ b/packages/cli-core/src/lib/package-manager.ts @@ -8,11 +8,20 @@ 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`: +// 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. const PM_INSTALL_COMMANDS = { - bun: "bun add", - yarn: "yarn add", - pnpm: "pnpm add", - npm: "npm install", + bun: "bun add --ignore-scripts", + yarn: "yarn add --ignore-scripts", + pnpm: "pnpm add --ignore-pnpmfile --ignore-scripts", + npm: "npm install --ignore-scripts", } satisfies Record; /** Returns the ` add`-style command for installing dependencies. */ From ed3359ff85556d670620fc1d3ebdb68dc28db225 Mon Sep 17 00:00:00 2001 From: Rafael Thayto Date: Thu, 21 May 2026 09:39:25 -0300 Subject: [PATCH 2/2] refactor(init): apply self-review feedback to security hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- .../src/commands/init/bootstrap-registry.ts | 16 +++++++++---- packages/cli-core/src/lib/installer.ts | 3 +-- .../cli-core/src/lib/package-manager.test.ts | 23 ++++++++----------- packages/cli-core/src/lib/package-manager.ts | 20 ++++++++++++---- 4 files changed, 36 insertions(+), 26 deletions(-) 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 006c58e8..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 type { PackageManager } from "./package-manager.ts"; // ── Types ──────────────────────────────────────────────────────────────────── @@ -363,7 +362,7 @@ const GLOBAL_UPDATE_COMMANDS = { bun: "bun add -g", pnpm: "pnpm add -g", npm: "npm install -g", -} satisfies Record, string>; +} satisfies Record, string>; /** Human-readable install/update command for the given installer. */ export function globalInstallCommand(installer: Installer, packageSpec: string): string { diff --git a/packages/cli-core/src/lib/package-manager.test.ts b/packages/cli-core/src/lib/package-manager.test.ts index 8d5203f6..ee276daf 100644 --- a/packages/cli-core/src/lib/package-manager.test.ts +++ b/packages/cli-core/src/lib/package-manager.test.ts @@ -2,28 +2,23 @@ import { test, expect, describe } from "bun:test"; import { pmInstallCommand, PACKAGE_MANAGERS } from "./package-manager.ts"; describe("pmInstallCommand hardening flags", () => { - // AIE-969: `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 + // `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("every package manager emits --ignore-scripts", () => { - for (const pm of PACKAGE_MANAGERS) { - expect(pmInstallCommand(pm)).toContain("--ignore-scripts"); - } + 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"); }); - test("first token is the bare binary name (spawn tokenization)", () => { - // heuristics.runPmInstall splits addCmd by " " and uses [0] as the binary - // it probes via Bun.which(). A flag-prefixed binary would break that. - expect(pmInstallCommand("bun").split(" ")[0]).toBe("bun"); - expect(pmInstallCommand("pnpm").split(" ")[0]).toBe("pnpm"); - expect(pmInstallCommand("yarn").split(" ")[0]).toBe("yarn"); - expect(pmInstallCommand("npm").split(" ")[0]).toBe("npm"); + // 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 2e9cf6ee..fddbe446 100644 --- a/packages/cli-core/src/lib/package-manager.ts +++ b/packages/cli-core/src/lib/package-manager.ts @@ -10,18 +10,28 @@ 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`: +// 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 --ignore-scripts", - yarn: "yarn add --ignore-scripts", - pnpm: "pnpm add --ignore-pnpmfile --ignore-scripts", - npm: "npm install --ignore-scripts", + 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. */