From b9df8bf4f50d8d6c3cd907df2328f4963ad2c93f Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 22 Jul 2026 19:38:22 +0000 Subject: [PATCH] fix(installer): pin npm registry trust boundary Co-Authored-By: Paperclip --- cli/src/__tests__/update-command.test.ts | 22 +++++++++++++++++++++ cli/src/commands/install.ts | 2 +- cli/src/commands/update.ts | 25 +++++++++++++++++++++--- scripts/install-sh-fixtures/npx | 7 +++++++ scripts/install.sh | 12 +++++++++--- scripts/test-install-sh-docker.sh | 22 +++++++++++++++++++++ 6 files changed, 83 insertions(+), 7 deletions(-) diff --git a/cli/src/__tests__/update-command.test.ts b/cli/src/__tests__/update-command.test.ts index 1547c9180da..f1c114bfbeb 100644 --- a/cli/src/__tests__/update-command.test.ts +++ b/cli/src/__tests__/update-command.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { flipCurrentAtomic, initializeInstallStore, payloadPathFor, readInstallManifest, resolveInstallStorePaths, writeInstallManifestAtomic, type InstallManifest, type InstallRecord } from "../install-store.js"; +import type { CommandRunner } from "../commands/install.js"; import { detectInstallMode, resolveUpdateRequest, rollbackManagedInstall, updateCommand } from "../commands/update.js"; let root: string; @@ -27,6 +28,7 @@ beforeEach(() => { process.env.PAPERCLIP_HOME = path.join(root, "paperclip"); }); afterEach(() => { + vi.unstubAllEnvs(); if (previousHome === undefined) delete process.env.HOME; else process.env.HOME = previousHome; if (previousPaperclipHome === undefined) delete process.env.PAPERCLIP_HOME; else process.env.PAPERCLIP_HOME = previousPaperclipHome; fs.rmSync(root, { recursive: true, force: true }); @@ -90,6 +92,26 @@ describe("update command", () => { await expect(updateCommand({ version: "1.0.0", dryRun: true }, { paths, executablePath: entrypoint, runCommand, confirm: async () => false })).rejects.toThrow("Downgrade cancelled"); }); + it("isolates global npm updates from hostile registry configuration", async () => { + const paths = resolveInstallStorePaths(); + const executable = path.join(root, "lib", "node_modules", "paperclipai", "dist", "index.js"); + vi.stubEnv("NPM_CONFIG_REGISTRY", "http://attacker-registry.invalid"); + fs.mkdirSync(process.env.HOME!, { recursive: true }); + fs.writeFileSync(path.join(process.env.HOME!, ".npmrc"), "registry=http://attacker-registry.invalid\n"); + const runCommand = vi.fn(async (_file: string, args: string[], commandOptions?: Parameters[2]) => { + if (args[0] === "view") return { stdout: '"2.0.0"\n', stderr: "" }; + expect(args).toContain("--registry=https://registry.npmjs.org"); + expect(args).toContain("--@paperclipai:registry=https://registry.npmjs.org"); + expect(commandOptions?.env?.NPM_CONFIG_REGISTRY).toBe("https://registry.npmjs.org"); + expect(commandOptions?.env?.npm_config_registry).toBe("https://registry.npmjs.org"); + expect(commandOptions?.env?.NPM_CONFIG_USERCONFIG).toBe(commandOptions?.env?.npm_config_userconfig); + expect(fs.readFileSync(commandOptions!.env!.NPM_CONFIG_USERCONFIG!, "utf8")).toContain("registry=https://registry.npmjs.org"); + return { stdout: "", stderr: "" }; + }); + await updateCommand({}, { paths, executablePath: executable, runCommand }); + expect(runCommand).toHaveBeenCalledTimes(2); + }); + it("backs up, installs side-by-side, flips, and rolls back instantly", async () => { const paths = resolveInstallStorePaths(); initializeInstallStore(paths); const oldPayload = payloadPathFor(paths, "npm", "1.0.0"); const executable = createPayload(oldPayload, "1.0.0"); flipCurrentAtomic(oldPayload, paths); diff --git a/cli/src/commands/install.ts b/cli/src/commands/install.ts index d75ad73432e..6c8a9b3a178 100644 --- a/cli/src/commands/install.ts +++ b/cli/src/commands/install.ts @@ -21,7 +21,7 @@ import { } from "../install-store.js"; const execFileAsync = promisify(execFile); -const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org"; +export const PUBLIC_NPM_REGISTRY = "https://registry.npmjs.org"; const DEFAULT_GITHUB_REPO = "paperclipai/paperclip"; const EXACT_VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/; diff --git a/cli/src/commands/update.ts b/cli/src/commands/update.ts index a4d0bcf92ad..a4e1dcdb33f 100644 --- a/cli/src/commands/update.ts +++ b/cli/src/commands/update.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { execFile } from "node:child_process"; import { promisify } from "node:util"; @@ -6,7 +7,7 @@ import * as p from "@clack/prompts"; import pc from "picocolors"; import { buildNextManifest, flipCurrentAtomic, isManagedExecutable, pruneInstallPayloads, readInstallManifest, resolveInstallStorePaths, withInstallStoreLock, writeInstallManifestAtomic, type InstallChannel, type InstallManifest, type InstallRecord, type InstallStorePaths } from "../install-store.js"; import { dbBackupCommand } from "./db-backup.js"; -import { installGitPayload, installNpmPayload, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js"; +import { installGitPayload, installNpmPayload, PUBLIC_NPM_REGISTRY, resolveGitHubRef, resolvePublishedVersion, type CommandRunner } from "./install.js"; const execFileAsync = promisify(execFile); export type InstallMode = "managed" | "global-npm" | "npx" | "source" | "unknown"; @@ -108,8 +109,26 @@ export async function updateCommand(options: UpdateOptions, overrides: Partial 0, downgrade: comparison < 0, channel: request.channel }, comparison > 0 ? `Update available: ${targetVersion}` : comparison < 0 ? `Target ${targetVersion} is older than ${currentVersion}.` : `paperclipai ${targetVersion} is current.`); if (comparison > 0) process.exitCode = 10; return; } if (mode === "global-npm") { - const args = ["install", "-g", `paperclipai@${targetVersion}`]; console.log(`Running: npm ${args.join(" ")}`); - if (!options.dryRun) await runCommand("npm", args, { maxBuffer: 16 * 1024 * 1024 }); + const args = ["install", "-g", `paperclipai@${targetVersion}`, `--registry=${PUBLIC_NPM_REGISTRY}`, `--@paperclipai:registry=${PUBLIC_NPM_REGISTRY}`]; console.log(`Running: npm ${args.join(" ")}`); + if (!options.dryRun) { + const npmConfigDir = fs.mkdtempSync(path.join(os.tmpdir(), "paperclip-npm-")); + const npmUserConfigPath = path.join(npmConfigDir, "npmrc"); + try { + fs.writeFileSync(npmUserConfigPath, `registry=${PUBLIC_NPM_REGISTRY}\n@paperclipai:registry=${PUBLIC_NPM_REGISTRY}\n`, { mode: 0o600 }); + await runCommand("npm", args, { + env: { + ...process.env, + npm_config_registry: PUBLIC_NPM_REGISTRY, + NPM_CONFIG_REGISTRY: PUBLIC_NPM_REGISTRY, + npm_config_userconfig: npmUserConfigPath, + NPM_CONFIG_USERCONFIG: npmUserConfigPath, + }, + maxBuffer: 16 * 1024 * 1024, + }); + } finally { + fs.rmSync(npmConfigDir, { recursive: true, force: true }); + } + } emit(options, { mode, action: "update", targetVersion, dryRun: Boolean(options.dryRun), command: ["npm", ...args] }, options.dryRun ? "Dry run complete." : pc.green(`Updated global npm install to ${targetVersion}.`)); return; } if (!manifest) throw new Error("Managed install metadata is missing."); diff --git a/scripts/install-sh-fixtures/npx b/scripts/install-sh-fixtures/npx index fa5a25772b2..9ff863ff8ac 100755 --- a/scripts/install-sh-fixtures/npx +++ b/scripts/install-sh-fixtures/npx @@ -3,4 +3,11 @@ set -euo pipefail : "${PAPERCLIP_INSTALL_TEST_LOG:?PAPERCLIP_INSTALL_TEST_LOG is required}" node --version >"${PAPERCLIP_INSTALL_TEST_LOG}.node" +printf 'NPM_CONFIG_REGISTRY=%s\n' "${NPM_CONFIG_REGISTRY:-}" >>"$PAPERCLIP_INSTALL_TEST_LOG" +printf 'npm_config_registry=%s\n' "${npm_config_registry:-}" >>"$PAPERCLIP_INSTALL_TEST_LOG" +printf 'NPM_CONFIG_USERCONFIG=%s\n' "${NPM_CONFIG_USERCONFIG:-}" >>"$PAPERCLIP_INSTALL_TEST_LOG" +printf 'npm_config_userconfig=%s\n' "${npm_config_userconfig:-}" >>"$PAPERCLIP_INSTALL_TEST_LOG" +if [ -n "${NPM_CONFIG_USERCONFIG:-}" ] && [ -f "$NPM_CONFIG_USERCONFIG" ]; then + sed 's/^/npmrc:/' "$NPM_CONFIG_USERCONFIG" >>"$PAPERCLIP_INSTALL_TEST_LOG" +fi printf '%s\n' "$@" >>"$PAPERCLIP_INSTALL_TEST_LOG" diff --git a/scripts/install.sh b/scripts/install.sh index 501c914de3a..c3cb6e31dc4 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -4,6 +4,7 @@ set -euo pipefail MIN_NODE_MAJOR=20 DEFAULT_NODE_MAJOR=22 PAPERCLIP_PACKAGE="paperclipai" +PUBLIC_NPM_REGISTRY="https://registry.npmjs.org" HOMEBREW_INSTALL_COMMIT="99e13e96cbbdc1ac1ac09c0a40b450bf219ef3aa" HOMEBREW_INSTALL_SHA256="99287f194a8b3c9e6b0203a11a5fa54518be57209343e6bb954dec4635796d9d" NODESOURCE_DEB_SHA256="575583bbac2fccc0b5edd0dbc03e222d9f9dc8d724da996d22754d6411104fd1" @@ -354,7 +355,12 @@ INSTALL_ARGS=(install) [ "$CANARY" = "1" ] && INSTALL_ARGS+=(--canary) [ -n "$VERSION" ] && INSTALL_ARGS+=(--version "$VERSION") [ "$NO_PROMPT" = "1" ] && INSTALL_ARGS+=(--yes) -INSTALL_COMMAND=(npx --yes "$PACKAGE_SPEC" "${INSTALL_ARGS[@]}") +ensure_temp_dir +NPM_USERCONFIG="$TEMP_DIR/npmrc" +printf 'registry=%s\n@paperclipai:registry=%s\n' "$PUBLIC_NPM_REGISTRY" "$PUBLIC_NPM_REGISTRY" >"$NPM_USERCONFIG" +chmod 600 "$NPM_USERCONFIG" +NPM_ENV=(env "NPM_CONFIG_REGISTRY=$PUBLIC_NPM_REGISTRY" "npm_config_registry=$PUBLIC_NPM_REGISTRY" "NPM_CONFIG_USERCONFIG=$NPM_USERCONFIG" "npm_config_userconfig=$NPM_USERCONFIG") +INSTALL_COMMAND=("${NPM_ENV[@]}" npx --yes "--registry=$PUBLIC_NPM_REGISTRY" "$PACKAGE_SPEC" "${INSTALL_ARGS[@]}") log "Delegating to the Paperclip CLI" if [ "$DRY_RUN" = "1" ]; then @@ -367,8 +373,8 @@ print_command "${INSTALL_COMMAND[@]}" if [ "$INSTALL_SERVICE" = "1" ]; then log "Installing the Paperclip service" - print_command npx --yes "$PACKAGE_SPEC" service install - npx --yes "$PACKAGE_SPEC" service install + print_command "${NPM_ENV[@]}" npx --yes "--registry=$PUBLIC_NPM_REGISTRY" "$PACKAGE_SPEC" service install + "${NPM_ENV[@]}" npx --yes "--registry=$PUBLIC_NPM_REGISTRY" "$PACKAGE_SPEC" service install fi if [ "$NO_ONBOARD" = "0" ] && [ -t 0 ] && [ -t 1 ]; then diff --git a/scripts/test-install-sh-docker.sh b/scripts/test-install-sh-docker.sh index e1e93e7a7ed..b12d0d89580 100755 --- a/scripts/test-install-sh-docker.sh +++ b/scripts/test-install-sh-docker.sh @@ -68,6 +68,28 @@ run_with_node with-node bash /paperclip-scripts/install.sh --no-onboard assert_line "$RESULTS_DIR/with-node.args" "paperclipai@latest" assert_line "$RESULTS_DIR/with-node.args" "install" assert_line "$RESULTS_DIR/with-node.args" "--yes" +assert_line "$RESULTS_DIR/with-node.args" "--registry=https://registry.npmjs.org" +assert_line "$RESULTS_DIR/with-node.args" "NPM_CONFIG_REGISTRY=https://registry.npmjs.org" +assert_line "$RESULTS_DIR/with-node.args" "npm_config_registry=https://registry.npmjs.org" +assert_line "$RESULTS_DIR/with-node.args" "npmrc:registry=https://registry.npmjs.org" + +echo "==> hostile npm config isolation" +mkdir -p "$RESULTS_DIR/hostile-home" +printf 'registry=http://attacker-registry.invalid\n' >"$RESULTS_DIR/hostile-home/.npmrc" +docker run --rm \ + -v "$REPO_ROOT/scripts:/paperclip-scripts:ro" \ + -v "$RESULTS_DIR:/results" \ + -e HOME=/results/hostile-home \ + -e NPM_CONFIG_REGISTRY=http://attacker-registry.invalid \ + -e npm_config_registry=http://attacker-registry.invalid \ + -e PAPERCLIP_INSTALL_TEST_LOG=/results/hostile.args \ + -e PATH="/paperclip-scripts/install-sh-fixtures:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \ + node:22-bookworm-slim \ + bash /paperclip-scripts/install.sh --no-onboard +assert_line "$RESULTS_DIR/hostile.args" "--registry=https://registry.npmjs.org" +assert_line "$RESULTS_DIR/hostile.args" "NPM_CONFIG_REGISTRY=https://registry.npmjs.org" +assert_line "$RESULTS_DIR/hostile.args" "npm_config_registry=https://registry.npmjs.org" +assert_line "$RESULTS_DIR/hostile.args" "npmrc:registry=https://registry.npmjs.org" echo "==> --ref master" if run_with_node ref-master bash /paperclip-scripts/install.sh --ref master --no-onboard; then