Three malicious npm packages flagged this week carried no malware in the package itself. Each declared one dependency as a bare HTTPS URL, and the code npm would install from that URL lived on a server the attacker controlled. A registry scan of the published package finds a one-line stub. npm has had settings that block this since May, and npm 12 turns the main ones on by default. But every current Node.js release line still bundles npm 10 or npm 11, where the default is to fetch the URL and run its install scripts.
This guide is for teams that install npm packages on developer machines and in CI. It covers what the week's advisories show, how npm treats URL dependencies, what we measured when the bytes behind a URL change, and a script that lists every dependency in a lockfile that didn't come from your registry.
The short answer
npm can install a dependency from any tarball URL, git repository, local file or directory, not only from the registry. A package on the registry can declare such a dependency, and npm fetches and installs it, running its install scripts, on every machine that installs the package. Set allow-remote=root and allow-git=root in your project or user .npmrc so only your own package.json can use those sources. npm 11.15.0 and later support both settings, and npm 12 defaults them to none. Node.js 22 and 20 bundle npm 10, which has neither, so upgrade npm on those machines, and commit a lockfile so that a URL can't serve new bytes without failing the install.
What this week's advisories show
GitHub's advisory database published 264 malware advisories between 9 and 16 October 2026: 216 for npm, 24 for RubyGems, 23 for PyPI and one for crates.io. Most carry GitHub's standard text, which says only that the package is malicious. The 79 npm advisories with a written analysis are more useful. Of those, 54 describe code that runs at install time through preinstall, install or postinstall.
Three describe the URL pattern:
| Package | Advisory | Dependency declared as |
|---|---|---|
liferay-workspace-scripts@99.9.1 | 11 Oct | a tarball in a Google Cloud Storage bucket |
@flexmr/survey-client | 16 Oct | https://artifacts.creditslib.com/npm/marker-index-symbol-schema |
@flexmr/survey-conversation | 16 Oct | https://artifacts.creditslib.com/npm/cache-format-syntax-tracer |
The analyses, from Amazon Inspector and published through the OpenSSF malicious-packages repository, describe the same shape each time. The published index.js is empty or a single require of the URL dependency, so "the external host is the entire payload surface". Version 99.9.1 and a vendor-looking scope are dependency confusion markers, an attempt to win resolution against an internal package with the same name. And whoever runs the host can change what it serves at any time, without publishing anything to npm.
How npm treats URL dependencies
A dependency spec in package.json doesn't have to be a version range. npm also accepts a tarball URL, a git URL or GitHub shorthand, a file: path to a tarball, and a directory. These sources skip the registry entirely. The registry's malware scanning and publish history say nothing about what a URL serves.
npm added four settings to control them, one per source type: allow-remote for tarball URLs, allow-git, allow-file and allow-directory. Each takes all, none or root. root allows the source only when it's declared in your own project's package.json, which is where you'd put one on purpose. The changelogs date them: allow-git arrived in npm 11.9.0 (4 February 2026), allow-remote in 11.14.0 (6 May), and all four were consolidated in 11.15.0 (20 May). npm 12.0.0, released on 8 July, changed the default for allow-git and allow-remote to none. It also blocks dependency install scripts by default unless your project's allowScripts policy permits them.
Most machines don't run npm 12. On 16 October, the latest Node.js releases bundled these npm versions:
| Node.js | Status | Bundled npm | allow-remote available? | Default |
|---|---|---|---|---|
| 26.12.0 | Current | 11.20.0 | yes | all |
| 24.22.0 | LTS | 11.19.1 | yes | all |
| 22.23.3 | LTS | 10.9.9 | no | n/a |
| 20.20.2 | LTS | 10.8.2 | no | n/a |
So unless someone ran npm install -g npm@12, the URL dependency gets fetched. On Node 22 and 20, there's no setting to stop it short of upgrading npm.
What we measured
We built a local lab with npm 11.19.0: a stub package, installed as a local tarball, that depends on http://127.0.0.1:8765/remote-dep.tgz, served by a local HTTP server. The served package has a harmless postinstall that writes a marker file. Nothing in the lab touched the public registry.
| Test (npm 11.19.0) | Result |
|---|---|
Default npm install, no lockfile | URL dependency fetched, postinstall ran, with a warning |
--allow-remote=none | Failed: EALLOWREMOTE, nothing fetched |
--allow-remote=root (URL declared by a dependency) | Failed: EALLOWREMOTE, nothing fetched |
--strict-allow-scripts=true | Failed before running the unapproved postinstall |
--ignore-scripts | URL dependency installed, script didn't run |
Server swaps the tarball, then npm ci with the lockfile | Failed: EINTEGRITY |
Server swaps the tarball, then npm install with the lockfile | Failed: EINTEGRITY |
Server swaps the tarball, then npm install without a lockfile | New bytes installed, no error |
Three things stand out.
The default runs the remote package's install script. npm 11.19.0 printed a warning naming the script, and ran it anyway. The allowScripts policy only refuses when you opt in with strict-allow-scripts.
allow-remote=root stopped the transitive URL before fetching it. The install failed with EALLOWREMOTE and the message "Fetching non-root packages of type "remote" have been disabled". That's the setting you want: a URL in your own package.json still works, and one buried in a dependency doesn't.
The lockfile pins what the URL served the first time. npm recorded a SHA-512 integrity value for the URL tarball, and when the server later served different bytes, both npm ci and npm install failed with EINTEGRITY. Without a lockfile, the same install took the new bytes silently. The weak moment is the first install, or any npm update, when npm records whatever the server returns. The URL's owner chooses those bytes, and can choose differently for different requesters.
Find non-registry dependencies in a lockfile
This script reads package-lock.json and reports every package that came from a URL, git, a file or a directory. For each, it says whether your package.json declared it or another dependency did, whether it has install scripts, and whether the lockfile pins its integrity. It exits with status 1 when a transitive dependency comes from a URL or git, so it can run in CI. It reads files only and uses the Python standard library.
#!/usr/bin/env python3
"""List npm dependencies that don't come from your registry.
Reads package-lock.json (lockfile v2 or v3) and reports every installed
package whose source is a tarball URL, a git repository, a local file or a
directory, which are the sources npm's allow-remote, allow-git, allow-file and
allow-directory settings control. For each one it says who asked for it (your
package.json, or another dependency) and whether it has install scripts.
Reads files only. Standard library only.
python3 npm_exotic_deps.py path/to/project [--registry https://registry.npmjs.org/]
"""
import argparse, json, os, re, sys
def kind(resolved, registry):
if not resolved:
return None
if resolved.startswith(("git+", "git:", "github:")) or re.match(r"^[\w.-]+/[\w.-]+#", resolved):
return "git"
if resolved.startswith("file:"):
return "file"
if resolved.startswith(("http://", "https://")):
return None if resolved.startswith(registry) else "remote"
return "directory"
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("project")
ap.add_argument("--registry", default="https://registry.npmjs.org/")
a = ap.parse_args()
lock_path = os.path.join(a.project, "package-lock.json")
try:
lock = json.load(open(lock_path))
except OSError:
sys.exit(f"{lock_path}: not found. Without a lockfile, every install fetches URL dependencies fresh.")
pkgs = lock.get("packages")
if not pkgs:
sys.exit("lockfile v1 is not supported; run npm install with npm 7 or later to upgrade it")
root = pkgs.get("", {})
root_deps = {**root.get("dependencies", {}), **root.get("devDependencies", {}),
**root.get("optionalDependencies", {})}
# who declares each dependency spec, so we can tell root from transitive
declared = {}
for path, meta in pkgs.items():
for dep, spec in {**meta.get("dependencies", {}), **meta.get("optionalDependencies", {})}.items():
declared.setdefault(dep, []).append((path or "(root)", spec))
findings = []
for path, meta in pkgs.items():
if not path or meta.get("link"):
continue
k = kind(meta.get("resolved"), a.registry)
if not k:
continue
name = meta.get("name") or path.rsplit("node_modules/", 1)[-1]
parents = [p for p, spec in declared.get(name, []) if not spec.startswith(("^", "~", ">", "<", "=")) and not re.match(r"^\d", spec)]
top = path.count("node_modules/") == 1 and name in root_deps
findings.append((k, name, meta.get("resolved"), "root" if top else "transitive",
", ".join(p.replace("node_modules/", "") for p in parents) or "?",
bool(meta.get("hasInstallScript")), bool(meta.get("integrity"))))
if not findings:
print("No URL, git, file or directory dependencies in the lockfile.")
return 0
worst = 0
for k, name, res, scope, parents, scripts, integ in sorted(findings):
flags = [scope, "install scripts" if scripts else "no install scripts",
"integrity pinned" if integ else "NO INTEGRITY"]
print(f"{k:<9} {name} ({'; '.join(flags)})")
print(f" from {res}")
print(f" declared by {parents}")
if scope == "transitive" and k in ("remote", "git"):
worst = 1
kinds = sorted({f[0] for f in findings})
print("\nTo block these at install time (npm 11.15 and later; npm 12 defaults remote and git to none):")
for k in kinds:
print(f" allow-{k}=root # in .npmrc: only your own package.json may use {k} sources")
return worst
if __name__ == "__main__":
sys.exit(main())
On the lab project:
$ python3 npm_exotic_deps.py scan
file stub (root; no install scripts; integrity pinned)
from file:stub-10.0.12.tgz
declared by (root)
remote remote-dep (transitive; install scripts; integrity pinned)
from http://127.0.0.1:8765/remote-dep.tgz
declared by stub
To block these at install time (npm 11.15 and later; npm 12 defaults remote and git to none):
allow-file=root # in .npmrc: only your own package.json may use file sources
allow-remote=root # in .npmrc: only your own package.json may use remote sources
The file: entry is the lab's own stub, declared at the root, which is the legitimate case root allows. The transitive remote entry with install scripts is the pattern from this week's advisories.
What to do
-
Set the source policy in each project's
.npmrc, and in CI images:allow-remote=root allow-git=rootAdd
allow-file=rootandallow-directory=rootif no dependency needs them. -
Upgrade npm where Node bundles npm 10.
npm install -g npm@11gives you the settings on Node 20.17 or later and Node 22.9 or later. npm 12, with the stricter defaults, needs Node 22.22.2 or later in the 22 line, 24.15.0 or later in the 24 line, or Node 26. -
Commit the lockfile and use
npm ciin CI, so a URL can't change what you install without failing the build. -
Review install scripts.
npm install-scripts lslists the dependencies that have them. Approve the ones you need inallowScriptsand setstrict-allow-scripts=true. Our post on why preinstall hooks still run after npm 12's defaults covers the gaps in that policy. -
Run the script on your lockfiles and investigate any transitive URL or git dependency. A legitimate package published to the registry rarely needs one.
The fair view
URL and git dependencies have real uses. Teams install forks from git while a fix waits upstream, internal tools ship as tarballs on an artifact server, and monorepos link directories. Blocking them everywhere breaks those workflows, which is presumably why npm's root mode exists and why the stricter defaults waited for a major version.
The URL pattern was also rare this week: three advisories out of 264. Install scripts on registry packages remain the common case, and a URL policy does nothing about them. But the two controls cover different things. allowScripts decides what runs. allow-remote decides where code comes from, and a payload that lives off the registry is one the registry can't scan, take down or keep a record of. On npm 11, both controls are a line of configuration away. On npm 10, they're an upgrade away.