A quarantining proxy for the npm and PyPI registries. It serves everything upstream serves, except releases younger than 7 days. The idea: if someone slips a malicious version into a popular package, you don't install it on day one — you install it on day eight, after the rest of the world has had a chance to find and yank it.
Runs as a single Cloudflare Worker. Upstream integrity hashes (dist.integrity, dist.shasum, PyPI hashes.sha256) are passed through unchanged, so your package manager still verifies what it downloaded against the original publisher's hash.
This repo is the source you run on your own Cloudflare account. There is no shared public instance — the whole point is to control the proxy your installs depend on.
- npm: filters
versionsandtime, rewritesdist-tags.latestto the newest version old enough to survive the quarantine window. Tarball URLs are rewritten to flow back through the worker so a client can't accidentally bypass the filter. - PyPI: speaks PEP 691 + PEP 700 simple-index JSON to read
upload-time. Also serves the HTML simple index and the legacy/pypi/<pkg>/jsonendpoint for tools that still want them. - Files: streamed straight through. Every file request re-checks the quarantine against the upstream index, so guessing a URL for a quarantined version returns HTTP 451.
The whole thing is one Worker. Cloudflare's free tier covers a solo developer or small team comfortably: 100k requests/day, 10ms CPU per request, no egress charges. Larger CI fleets, or filtering massive metadata documents like @types/node, want the $5/month paid plan for the 30s CPU ceiling.
git clone https://github.com/FenkoHQ/foxymirror.git
cd foxymirror
npm install
npx wrangler login
npx wrangler deploy
Wrangler prints the workers.dev URL it deployed to. That URL is yours; everything that follows assumes you'll substitute it in.
In wrangler.toml:
routes = [
{ pattern = "mirror.example.com/*", zone_name = "example.com" }
]Then npx wrangler deploy again.
In wrangler.toml under [vars]:
| Variable | Default | What it does |
|---|---|---|
QUARANTINE_DAYS |
7 |
How old a release must be before it's served |
UPSTREAM_NPM |
https://registry.npmjs.org |
npm upstream |
UPSTREAM_PYPI_SIMPLE |
https://pypi.org/simple |
PyPI simple index upstream |
UPSTREAM_PYPI_JSON |
https://pypi.org/pypi |
PyPI legacy JSON upstream |
UPSTREAM_PYPI_FILES |
https://files.pythonhosted.org |
PyPI file storage |
Workers are public by default. If you don't want random people on the internet using your quarantine compute, gate the worker — Cloudflare Access on the route, an Authorization header check in src/index.ts, or an IP allowlist. The hashes are still upstream's, so an outsider can't poison anything by hitting your mirror; the concern is bill and rate-limit.
Substitute https://your-worker.workers.dev (or your custom domain) below.
One install:
npm install --registry https://your-worker.workers.dev/npm/ <pkg>
Persistent for this user:
npm config set registry https://your-worker.workers.dev/npm/
Per-project — drop a .npmrc next to package.json:
registry=https://your-worker.workers.dev/npm/
yarn config set npmRegistryServer https://your-worker.workers.dev/npm/
One install:
pip install --index-url https://your-worker.workers.dev/pypi/simple/ <pkg>
Persistent — ~/.config/pip/pip.conf:
[global]
index-url = https://your-worker.workers.dev/pypi/simple/
export UV_INDEX_URL=https://your-worker.workers.dev/pypi/simple/
Or in pyproject.toml:
[[tool.uv.index]]
url = "https://your-worker.workers.dev/pypi/simple/"
default = truepoetry source add --priority=default foxymirror https://your-worker.workers.dev/pypi/simple/
If you want every shell on the box to pick up the mirror without anyone configuring anything, drop the standard env vars into /etc/environment:
npm_config_registry=https://your-worker.workers.dev/npm/
YARN_NPM_REGISTRY_SERVER=https://your-worker.workers.dev/npm/
PIP_INDEX_URL=https://your-worker.workers.dev/pypi/simple/
UV_INDEX_URL=https://your-worker.workers.dev/pypi/simple/
UV_DEFAULT_INDEX=https://your-worker.workers.dev/pypi/simple/
PIPENV_PYPI_MIRROR=https://your-worker.workers.dev/pypi/simple/
This is the env-var matrix the major tools recognise:
| Tool | Env var | Notes |
|---|---|---|
| npm | npm_config_registry |
npm maps every npm_config_<key> to a config option |
| pnpm | npm_config_registry |
reads npm's |
| Yarn classic | npm_config_registry |
reads npm's |
| Yarn Berry | YARN_NPM_REGISTRY_SERVER |
|
| pip | PIP_INDEX_URL |
also PIP_EXTRA_INDEX_URL for fallbacks |
| uv | UV_INDEX_URL, UV_DEFAULT_INDEX |
set both for forward/backward compat |
| pipenv | PIPENV_PYPI_MIRROR |
|
| Poetry | none clean | needs poetry source add or poetry.toml |
A few things to know before you commit to the /etc/environment approach:
- It's not a shell file. PAM parses it as plain
KEY=valuelines. Noexport, no shell expansion, no$VARreferences. The block above is fine as-is; just don't try to be clever inside it. - systemd services don't read it. Login sessions, sshd, su, and GUI sessions do (via PAM), so interactive shells and the tools they launch pick it up. Anything running as a systemd service — self-hosted CI runners, the Docker daemon, sidecar agents — does not. For those, either add
EnvironmentFile=/etc/environmentto the unit (under/etc/systemd/system/<service>.d/foxymirror.conf) andsystemctl daemon-reload && systemctl restart <service>, or set the vars in/etc/systemd/system.confunderDefaultEnvironment=for system-wide coverage. - Containers don't inherit it. Docker/Podman containers start with their own env. Either
--env-file /etc/environmentat run time, bake the vars into the image, or set them in yourcompose.yaml. - Per-install escape hatch. If you ever need to go straight to upstream for one command without un-setting anything globally:
env npm_config_registry=https://registry.npmjs.org/ npm install <pkg> env PIP_INDEX_URL=https://pypi.org/simple/ pip install <pkg>
Substitute your worker URL for https://your-worker.workers.dev below. Each test takes a few seconds and proves a different invariant.
1. The mirror is reachable and filtering something. Any active package will show non-zero drops:
curl -sI https://your-worker.workers.dev/npm/react | grep -i x-foxymirror
# x-foxymirror-dropped: <some number>
# x-foxymirror-kept: <larger number>
2. The mirror serves an older latest than upstream when upstream's latest was published in the last 7 days. Without installing anything:
# pip
pip index versions --index-url https://your-worker.workers.dev/pypi/simple/ anthropic | head -2
pip index versions --index-url https://pypi.org/simple/ anthropic | head -2
# npm
npm view --registry https://your-worker.workers.dev/npm/ playwright version
npm view --registry https://registry.npmjs.org/ playwright version
If upstream's headline release is older than a week, both lines match — that's correct too.
3. File bytes are untouched. Pull the same tarball through the mirror and direct, compare hashes:
curl -s -o /tmp/m.tgz https://your-worker.workers.dev/npm/react/-/react-19.2.5.tgz
curl -s -o /tmp/u.tgz https://registry.npmjs.org/react/-/react-19.2.5.tgz
sha256sum /tmp/m.tgz /tmp/u.tgz
Both checksums must be identical. If they aren't, something is wrong — bail and investigate before installing anything.
4. Quarantined files refuse direct access. Pick a release upstream published in the last few days and hit its tarball URL on your mirror directly:
curl -sI https://your-worker.workers.dev/npm/react/-/react-<recent-version>.tgz | head -1
# HTTP/2 451
Same idea for PyPI:
curl -sI https://your-worker.workers.dev/pypi/files/anthropic/anthropic-<recent-version>-py3-none-any.whl | head -1
# HTTP/2 451
5. A pinned-but-quarantined version fails the resolver. Inside a throwaway directory or venv:
# npm — pin a version younger than the window
mkdir -p /tmp/foxy-npm && cd /tmp/foxy-npm && npm init -y >/dev/null
npm install --registry https://your-worker.workers.dev/npm/ react@<recent-version>
# → npm error notarget No matching version found
# pip — same test
python -m venv /tmp/foxy-pip && /tmp/foxy-pip/bin/pip install \
--index-url https://your-worker.workers.dev/pypi/simple/ "anthropic==<recent-version>"
# → Could not find a version that satisfies the requirement
The pinned-version test is the most important one — it's the actual scenario you're defending against, where a malicious release sits at the top of upstream and you would otherwise pin to it.
A version inside the 7-day window simply doesn't appear in the metadata. The install will either pick an older version that satisfies the range, or fail with "no matching version found". If you genuinely need a release that fresh, fall back to upstream for that one install:
npm install --registry https://registry.npmjs.org/ <pkg>
pip install --index-url https://pypi.org/simple/ <pkg>
You can check the response headers to see how many versions were dropped:
curl -sI https://your-worker.workers.dev/npm/react | grep -i x-foxymirror
npm install
npx wrangler dev # serves on http://127.0.0.1:8787
npm test # vitest
npx tsc --noEmit # typecheck
- A package that was already malicious 7 days ago. Quarantine buys detection time; it doesn't detect anything itself. Pair it with normal advisories.
- Postinstall scripts. If you
npm installa quarantined-but-clean version that runs an evil postinstall script three months later, this mirror doesn't help. Use--ignore-scriptsorpnpm's safer defaults. - Typosquats. The mirror serves whatever you ask for; if you ask for the wrong package it'll happily quarantine that too and then hand it over.
- Yanked-and-re-uploaded versions. Both registries permit this in some cases. If a poisoned version was yanked and replaced under the same number, the new copy still has to age 7 days before this mirror serves it.
MIT.