Skip to main content

Filter Plugin

What's a filter plugin?​

When to use a filter plugin?​

Use one when the metadata a client receives has to differ from what is stored: hiding versions by policy, rewriting a field, enforcing an internal rule on what may be installed.

Every manifest request goes through it. Tarball, user, profile and token requests do not.

What that looks like per command​

Two consequences, and both bite people:

  • A hidden version is still downloadable. Removing it from versions only changes what the manifest advertises; the tarball route never consults a filter, so anyone who knows the URL gets the file. A filter is a presentation rule, not an access control — that is what auth plugins are for.
  • Search multiplies the cost. A single search that matches two hundred packages calls your filter two hundred times before answering. This is why the caveats below insist on bailing out before doing any work.

A worked example to read is verdaccio-plugin-secfilter.

Plugin structure​

The plugin only has one async method named filter_metadata that reference of the manifest and must return a copy (or modified object but not recommended) of the metadata.

import { pluginUtils } from '@verdaccio/core';
import type { Manifest } from '@verdaccio/types';

/**
* Options read from your plugin's key in `filters:` of `config.yaml`.
*
* ```yaml
* filters:
* hide-canary:
* suffix: '-canary.'
* ```
*/
type CustomConfig = { suffix?: string };

/**
* A filter plugin sees every package manifest before it reaches the client, and can rewrite
* it. Tarball, user, profile and token requests do **not** pass through filters, so hiding a
* version here does not stop someone downloading its tarball directly.
*
* @see https://verdaccio.org/dev/plugin-filter
*/
export default class HideCanaryVersions
extends pluginUtils.Plugin<CustomConfig>
implements pluginUtils.ManifestFilter<CustomConfig>
{
/**
* Called for every manifest read — and once per matched package during `npm search`, so
* the cost is multiplied by the size of the result set. The three habits below are what
* keep that affordable; see the caveats on the docs page for the reasoning.
*
* The manifest you receive is a **shallow** copy: `versions`, `dist-tags`, `time` and
* `_distfiles` are the caller's own objects. Clone the containers you touch and never
* edit them in place, or you corrupt what Verdaccio later writes to storage.
*
* @param manifest the package manifest, already merged with any uplink data
* @returns the manifest the client will see
*/
public async filter_metadata(manifest: Manifest): Promise<Manifest> {
const suffix = (this.config as CustomConfig).suffix;

// 1. Bail out before cloning when there is nothing to do.
if (!suffix) {
return manifest;
}

const blocked = Object.keys(manifest.versions).filter((version) => version.includes(suffix));
if (blocked.length === 0) {
return manifest;
}

// 2. Clone the containers once, then work on that copy.
const filtered: Manifest = {
...manifest,
versions: { ...manifest.versions },
'dist-tags': { ...manifest['dist-tags'] },
time: { ...manifest.time },
};

// 3. One pass over what you already know is blocked, not a scan per container.
for (const version of blocked) {
delete filtered.versions[version];
delete filtered.time[version];
}
for (const [tag, version] of Object.entries(filtered['dist-tags'])) {
if (!filtered.versions[version]) {
delete filtered['dist-tags'][tag];
}
}

return filtered;
}
}

Caveats​

Filters look like the cheapest plugin type and are the easiest to make expensive. Everything here comes from problems that were actually hit and fixed in @verdaccio/package-filter, which is worth reading as the reference implementation.

It runs on the hot path, and npm search multiplies it​

filter_metadata is called for every manifest read. It is also called once per matched package while serving /-/v1/search (packages/store/src/storage.ts), so a search that matches two hundred packages runs your filter two hundred times before answering.

That is what turns an innocent-looking deep clone into a visible slowdown: the cost is multiplied by the result set, not paid once.

Bail out before you clone​

The first thing to do is decide whether you have anything to change, and return the manifest untouched if you do not. The official plugin does exactly this, with the search path named in the comment:

// Fast path: when no filter is configured there is nothing this plugin can
// change. Avoid cloning and cleanup for the `npm search` hot path.
if (blockRules.size === 0 && !excludeDeprecated && !earliestDateThreshold) {
return manifest as Manifest;
}

Clone once, not per pass​

When you do have work, clone the manifest and its mutable containers once, then let every pass operate on that copy. Chaining passes that each clone again is the double work that is easy to introduce when a filter grows a second rule.

Hoist per-manifest work out of per-version loops​

Anything that depends on the manifest rather than the version — matching the package name against allow rules, parsing configuration, building a Date — belongs above the loop. Recomputing it for each version is the other double loop, and it scales with packages that have hundreds of versions.

The copy you get is shallow​

The pipeline hands you { ...manifest }. versions, dist-tags, time and _distfiles are still the caller's objects, so editing them in place mutates what Verdaccio later persists. Clone the containers you touch.

Removing versions leaves debris​

Deleting entries from versions is not the whole job: dist-tags can end up pointing at a version that no longer exists — including latest, which breaks npm install — and time and _distfiles keep orphaned entries. Repair what you break, and only when you actually filtered something.

Filters chain, and a failure is silent​

Filters run in sequence, each receiving the previous one's output, so their costs add up. If one throws, Verdaccio logs it and carries on with the last good manifest: a broken filter fails open and quietly serves unfiltered metadata. Do not rely on a filter as a security boundary.

No I/O​

filter_metadata is async, which makes a network call look acceptable. On the search path it is not. Load what you need at startup and keep the filter in memory.

Configuration​

Just add filters to the config.yaml file and your own plugin options.

filters:
storage-filter-blackwhitelist:
filter_file: /path/to/file

More info in the PR.