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
5 changes: 5 additions & 0 deletions .changeset/fix-pnpmfile-autoload.md
Original file line number Diff line number Diff line change
@@ -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).
16 changes: 11 additions & 5 deletions packages/cli-core/src/commands/init/bootstrap-registry.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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<PackageManager, string[]> = {
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],
};
18 changes: 13 additions & 5 deletions packages/cli-core/src/lib/installer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -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<Exclude<Installer, "homebrew" | "yarn">, 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 `<pm> 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}`;
}
24 changes: 24 additions & 0 deletions packages/cli-core/src/lib/package-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
27 changes: 23 additions & 4 deletions packages/cli-core/src/lib/package-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<pm> add`/`<pm> 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<PackageManager, readonly string[]>;

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<PackageManager, string>;

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